PROCITY/pipeline/build_manifest.py
m3ultra 55f6698fb2 Lane E R14 (v3.0 release): provenance freeze + doc-consistency fixes
Provenance close-out for the v3.0 tag. Verified via a 6-agent inventory+audit workflow
(both adversarial audits PASS, 0 blockers) cross-checked against glb_stat/ffprobe/manifest.

- LANE_E_NOTES: v3.0-FROZEN provenance table + full audio(28)/GLB(38)/skins(96)/packs(3)
  inventories; by-design notes + v3.1 deferrals (street_bin decimation).
- AUDIT.md: add R13 gig-layer finalization section (14k decimation, _hi published, gigKey
  rename + 2 beds, depot publish done) — ledger was stale at R12.
- manifest glb_law string: state the documented <=8k-hero / <=14k-instrument tiers so the
  frozen contract is self-consistent (regenerated; 1-line diff, Gate 3 still 0/0).
- gen_audio: roomtone-video gain 0.3 -> 0.35 to match the shipped manifest.

Gate 3 GREEN; publish.py --verify clean; 0 quarantine assets in manifest; goldens unmoved
(synthetic 0x3fa36874, gig 0x1f636349). No new synthesis (genres final); no poster templates
(no ask from B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:18:40 +10:00

313 lines
17 KiB
Python

#!/usr/bin/env python3
"""Generate web/assets/manifest.json — the single contract Lanes B/C/D read.
Mechanical parts (skin listing, fitting footprints) are derived from the filesystem +
the normalizer's _normalize_results.json; the curated parts (which facade suits which
shop type) are the maps below, seeded from CITY_SPEC's registry table. Re-run any time
skins or normalized GLBs change: python3 pipeline/build_manifest.py
Everything here is OPTIONAL at runtime — the game runs asset-free (CITY_SPEC house law).
The manifest just tells the other lanes what nicer assets exist and how big they are.
"""
import json, os, glob
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "web", "assets", "gen")
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") # library GLBs
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
GIG_RESULTS = os.path.join(ROOT, "pipeline", "_gig_results.json") # round-12 gig instruments
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
DEPOT = "https://digalot.fyi/3god"
# ── curated audio contract (files from pipeline/gen_audio.py). Interior beds/music carry types[]
# — shop types that use them — so Lane C/F seed-pick from the matching pool, exactly like facades. ──
AUDIO_STREET = {"street-day": 0.5, "street-night": 0.5, "rain": 0.45} # Lane B street beds
AUDIO_ROOMTONE = { # interior room-tone key -> shop types it suits
"retail": ["record", "opshop", "book", "toy", "pawn", "stall", "general"],
"milkbar": ["milkbar"],
"video": ["video", "dept", "arcade"],
}
AUDIO_MUSIC = { # interior music key -> (gain, shop types, note)
"record-shop": (0.55, ["record"], "record shop plays music"),
"milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
"video-synth": (0.50, ["video"], "video store synth hum"),
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"),
# ── gig beds: the manifest key IS the gigKey, ALWAYS `gig-<genreKey>` (R13 debt #1; one key,
# zero mapping). C emits room.audio.gigKey='gig-'+genreKey, F/B read this key directly. ──
"gig-pubrock": (0.60, ["pub"], "gig live bed — pub-rock (pub); F plays via room.audio gigKey"),
"gig-grunge": (0.60, ["band_room"], "gig live bed — grunge (band_room)"),
"gig-covers": (0.50, ["rsl"], "gig live bed — covers (RSL); mellower, organ-led"),
}
AUDIO_INTERIOR_AMB = {"crowd-walla": (0.4, "gig crowd layer (interior; F plays when gig on)")} # R12
AUDIO_SFX = {"doorbell": 0.7, "door-open": 0.6, "till": 0.7, "riffle": 0.6, # one-shots
"toast": 0.5, "tram-bell": 0.7, "applause": 0.7} # applause = R12 gig
AUDIO_SFX_LOOP = {"tram-rumble": 0.5}
AUDIO_FOOTSTEPS = {"pavement": 3, "timber": 3} # surface -> variants
# ── depot-reuse: already-published GLBs we point at without a local normalize. The round-3 reuse
# check found vintage-cash-register.glb live on 3GOD (from thriftgod_hero) — reuse it for the
# universal till instead of re-generating. Manual footprint/height (no local file to measure);
# no local thumb, so it isn't staged for ?localdepot (falls back to primitive there). ──
DEPOT_REUSE = {
"cash_register": {"file": "vintage-cash-register.glb", "footprint": [0.35, 0.28], "height": 0.35},
}
# ── curated: facade skin → shop types it suits ─────────────────────────────────────────
# MIRRORS Lane A's web/js/core/registry.js SHOP_TYPES[*].facades (the authoritative render-time
# pool) so there is ONE consistent mapping, plus additive-only coverage: cashtheists→pawn (a pawn
# facade registry leaves unreferenced), corrugated→stall (registry's stall pool has only `market`;
# this is stall's 2nd skin — flagged for Lane A), djsim-pub→general.
FACADE_TYPES = {
"timber-teal": ["record"], "arcade-tile": ["record"], "djsim-record": ["record"],
"weatherboard": ["opshop"], "fibro-blue": ["opshop"], "djsim-opshop": ["opshop"],
"stucco-pink": ["toy"], "deco-pastel": ["toy"], "boutique": ["toy"],
"federation": ["book"], "sandstone": ["book"], "redbrick": ["book"],
"stripmall": ["video"], "djsim-video": ["video"],
"besser": ["pawn"], "grimy": ["pawn"], "djsim-pawn": ["pawn"],
"corrugated": ["pawn", "stall"], "cashtheists": ["pawn"],
"djsim-milkbar": ["milkbar"], "brickveneer": ["milkbar"],
"terrazzo": ["dept"], "djsim-dept": ["dept"],
"market": ["stall"],
"djsim-pub": ["general"],
# ── Step-4 gap-fill fronts, generated on-device (MODELBEAST flux2-klein-4b, free) ──────
# docs/shots/laneE/facades_gen_local.png. Additive to registry.js: each fattens a thin
# pool so no type leans on one skin (video 2→4, milkbar 2→5, dept 2→4, stall 1→3).
"toy-brights": ["toy"], "toy-lolly": ["toy"],
"book-barn": ["book"], "book-scholarly": ["book"],
"video-neon": ["video"], "video-suburban": ["video"],
"milkbar-corner": ["milkbar"], "milkbar-fibro": ["milkbar"], "milkbar-deco": ["milkbar"],
"dept-anchor": ["dept"], "dept-emporium": ["dept"],
"market-fruit": ["stall"], "market-edge": ["stall"],
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
"arcade-entry": ["general"], # arcade portal, not a shop — Lane B places by key
# residential fronts for use:'house' lots (incl. the milkbar corner-shop's street)
"res-terrace": ["house"], "res-weatherboard": ["house"],
"res-fibro": ["house"], "res-brickveneer": ["house"],
}
# facades that already carry BAKED sign text (mostly the 90sDJsim Flux ones) — Lane B should NOT
# overlay a shop name on these; the blank-signboard ones are the clean overlay targets. Verified
# by eye against docs/shots/laneE/facades_style_anchor.png.
BAKED_SIGN = {"djsim-milkbar", "djsim-video", "djsim-pub", "market", "grimy"}
# ── curated: ground skin → where it belongs ─────────────────────────────────────────────
GROUND_USE = {
"asphalt1": "road", "asphalt2": "road", "djsim-road": "road",
"footpath1": "footpath", "footpath2": "footpath", "djsim-footpath": "footpath",
"grass": "yard", "brickpave": "plaza", "gravel": "verge", "reddust": "outback",
}
# ── curated: interior floor / surface split of the tex-* skins ───────────────────────────
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",
"boards-polished"]
SURFACE_TEX = { # interior wall / counter surfaces → biased shop types (hint only)
"wood-counter": ["record", "milkbar", "pawn"], "boutique-wall": ["toy", "opshop"],
"pegboard": ["pawn", "video"], "brick": ["general"], "render": ["general"],
"grimy-wall": ["pawn"], "velvet-curtain": ["toy", "record"],
}
AWNING_TEX = ["awning-red", "awning-green", "awning-blue"]
def rel(p):
return "gen/" + os.path.basename(p)
def build_audio():
"""The audio section: ambience (street beds + interior roomtone), sfx (one-shots + footstep
variant arrays), music. Only assets present on disk are listed — silent-happy by construction."""
def ref(n):
return ({"file": f"audio/{n}.ogg", "fallback": f"audio/{n}.m4a"}
if os.path.isfile(os.path.join(AUDIO, n + ".ogg")) else None)
ambience = {}
for k, g in AUDIO_STREET.items():
r = ref("ambience-" + k)
if r:
ambience[k] = {**r, "loop": True, "gain": g, "scope": "street"}
for k, types in AUDIO_ROOMTONE.items():
r = ref("roomtone-" + k)
if r:
ambience["roomtone-" + k] = {**r, "loop": True, "gain": 0.35, "scope": "interior", "types": types}
for k, (g, note) in AUDIO_INTERIOR_AMB.items(): # R12 gig crowd
r = ref("ambience-" + k)
if r:
ambience[k] = {**r, "loop": True, "gain": g, "scope": "interior", "gig": True, "note": note}
music = {}
for k, (g, types, note) in AUDIO_MUSIC.items():
r = ref("music-" + k)
if r:
music[k] = {**r, "loop": True, "gain": g, "types": types, "note": note}
sfx = {}
for k, g in AUDIO_SFX.items():
r = ref("sfx-" + k)
if r:
sfx[k] = {**r, "gain": g}
for k, g in AUDIO_SFX_LOOP.items():
r = ref("sfx-" + k)
if r:
sfx[k] = {**r, "gain": g, "loop": True}
footsteps = {}
for surf, n in AUDIO_FOOTSTEPS.items():
vs = [ref(f"step-{surf}-{i}") for i in range(1, n + 1)]
vs = [v for v in vs if v]
if vs:
footsteps[surf] = vs
if footsteps:
sfx["footstep"] = footsteps
for k in music: # flag gig-only beds for F (any 'gig-*' key)
if k.startswith("gig-"):
music[k]["gig"] = True
if "applause" in sfx:
sfx["applause"]["gig"] = True
return {"ambience": ambience, "sfx": sfx, "music": music}
def build():
have = {os.path.splitext(os.path.basename(f))[0] for f in glob.glob(GEN + "/*.jpg")}
def f(cat, key):
name = f"{cat}-{key}"
return f"gen/{name}.jpg" if name in have else None
# ---- facades ----
facade = {}
for key, types in FACADE_TYPES.items():
file = f("facade", key)
if file:
facade[key] = {"file": file, "types": types,
"signboard": "baked" if key in BAKED_SIGN else "blank"}
# any facade-*.jpg not in the curated map → catalogue it so nothing is orphaned.
# `-side` skins are corner-lot SIDE walls (windowless): face:"side" + empty types so no
# shop-front pool ever selects one — Lane B picks them by the facade-<x>-side.jpg name.
for name in sorted(have):
if not name.startswith("facade-") or name[len("facade-"):] in facade:
continue
key = name[len("facade-"):]
is_side = key.endswith("-side")
entry = {"file": "gen/" + name + ".jpg",
"types": [] if is_side else ["general"],
"signboard": "baked" if key in BAKED_SIGN else "blank"}
if is_side:
entry["face"] = "side"
facade[key] = entry
# ---- skies (array; not type-specific) ----
sky = [{"id": n[len("sky-"):], "file": "gen/" + n + ".jpg"}
for n in sorted(have) if n.startswith("sky-")]
# ---- grounds (object keyed by id, with a use tag) ----
ground = {}
for name in sorted(have):
if name.startswith("ground-"):
key = name[len("ground-"):]
ground[key] = {"file": "gen/" + name + ".jpg", "use": GROUND_USE.get(key, "road")}
# ---- interior wallpapers (array) ----
wall = [{"id": n[len("wall-"):], "file": "gen/" + n + ".jpg"}
for n in sorted(have) if n.startswith("wall-")]
# ---- interior floors + surfaces + street awnings (from tex-*) ----
floor = [{"id": k, "file": f("tex", k)} for k in FLOOR_TEX if f("tex", k)]
surface = [{"id": k, "file": f("tex", k), "types": t}
for k, t in SURFACE_TEX.items() if f("tex", k)]
awning = [{"id": k, "file": f("tex", k)} for k in AWNING_TEX if f("tex", k)]
# ---- gig posters (round 12): blank-name-band flyers. B/C overprint the seeded band name in
# nameZone = [x,y,w,h] fractions of the image (the top banner) — like blank-signboard facades ----
POSTER_NAME_ZONE = [0.08, 0.04, 0.84, 0.23]
poster = {}
for name in sorted(have):
if name.startswith("poster-"):
poster[name[len("poster-"):]] = {"file": "gen/" + name + ".jpg",
"nameZone": POSTER_NAME_ZONE, "signboard": "blank"}
skins = {
"facade": facade,
"sky": sky,
"ground": ground,
"wall": wall,
"interior": {"floor": floor, "surface": surface},
"awning": awning,
"poster": poster,
}
# ---- fittings / furniture from the normalizer results (library + round-3 hero props) ----
fittings, furniture = {}, {}
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
DROP = {"procity_fit_cube_shelf_01.glb"}
for results_file in (RESULTS, PROPS_RESULTS, GIG_RESULTS):
if not os.path.exists(results_file):
continue
for r in json.load(open(results_file)).get("results", []):
if r["file"] in DROP:
continue
fid = r["file"].replace("procity_fit_", "").replace("procity_street_", "").replace("_01.glb", "")
entry = {
"file": r["file"],
"footprint": r["footprint"], # [w, d] metres, on the ground
"height": r["size_m"][1], # metres
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
}
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
# depot-reuse assets (no local normalize) — e.g. the reused vintage cash register
for fid, meta in DEPOT_REUSE.items():
fittings.setdefault(fid, dict(meta))
# registry.js semantic fitting id → our physical asset id (only where a GLB exists; the rest
# stay primitives — see MESHGOD_BATCH.md). Lets a consumer that iterates registry SHOP_TYPES.fittings
# resolve to a GLB. Lane C's glb.js already maps its own kinds straight to the physical ids below.
fitting_aliases = {
# registry 'counter' → the ~2m counter-with-till (counter_till), NOT the 4m balcao 'counter'
"record_bins": "record_crate", "crates": "box_crate", "counter": "counter_till",
"clothes_racks": "clothes_rack", "bricabrac_shelving": "wire_shelf", "book_wall": "bookshelf",
"cube_shelves": "cube_shelf_wide", "display_tables": "coffee_table",
"bookshelves": "bookshelf", "vhs_shelving": "wire_shelf", "trestle_tables": "work_table",
# round-5 fal/MODELBEAST hero props → their registry semantic ids
"glass_case": "glass_case", "glass_cabinets": "glass_case", "magazine_rack": "magazine_rack",
"fridge": "drinks_fridge", "listening_corner": "listening_booth",
}
fitting_aliases = {k: v for k, v in fitting_aliases.items() if v in fittings}
manifest = {
"version": 1,
"depot": DEPOT,
"conventions": {
"glb_law": "metres, +Y up, origin at base (minY=0, centred X/Z), WebP <=1024, no Draco; "
"tris <=5k for library props, with documented exceptions for focal/LOD-able assets: "
"baked hero props <=8k (TRELLIS shell-soup, texture-intact) and gig instruments <=14k "
"(v3.0; hi-poly _hi originals on the depot) — see AUDIT.md + LANE_E_NOTES",
"facing": "symmetric props are orientation-agnostic; normalize.py applies no automatic "
"facing-correction (per-asset yaw is available in batch.json) — Lane B/C rotate "
"directional props (counter, food_cart) to taste when placing",
"facade_blank_signboard": "facades tagged signboard:'blank' ship a blank sign panel — "
"Lane B overlays the shop name there. signboard:'baked' facades "
"(some djsim-* Flux skins) already have sign text: use as-is, don't overlay",
"facade_face": "facade entries without a 'face' key are front elevations (shop or "
"house fronts); face:'side' entries are windowless corner-lot side "
"walls with empty types — never select one as a shop front",
"corner_lots": "corner lots expose a side wall: dedicated facade-<x>-side.jpg skins "
"now exist for brick/render/weatherboard (face:'side') — Lane B maps "
"these to the exposed side face; other types reuse the front skin",
"fallbacks": "every asset is optional — missing file => primitive/flat-colour, never a crash",
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
"audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a "
"suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick "
"per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any "
"missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch.",
},
"skins": skins,
"fittings": fittings,
"furniture": furniture,
"fittingAliases": fitting_aliases,
"audio": build_audio(),
}
with open(OUT, "w") as fh:
json.dump(manifest, fh, indent=2)
print(f"wrote {OUT}")
print(f" facades {len(facade)} skies {len(sky)} grounds {len(ground)} "
f"wallpapers {len(wall)} floors {len(floor)} surfaces {len(surface)} awnings {len(awning)}")
print(f" fittings {len(fittings)} furniture {len(furniture)}")
au = manifest["audio"]
print(f" audio: ambience {len(au['ambience'])} sfx {len(au['sfx'])} music {len(au['music'])}")
if __name__ == "__main__":
build()