PROCITY/pipeline/build_manifest.py
m3ultra 12eb571357 Lane E R39 (6/n): THREE GLBs shipped as GLASS, not one — transmissionFactor 1 stripped, published, sha1-verified, and now GATED. Plus the arcade's three meshes.
Lane C measured the bookshelf's transmission halving interior draws (opshop/hall 191->104, book/hall
101->51, dept/hall 116->116 control). The cause, with vendored line numbers: three.module.js:16209
enters renderTransmissionPass(opaqueObjects, ...) whenever currentRenderList.transmissive is
non-empty, and :16433 re-renders the WHOLE OPAQUE LIST into a render target — every opaque draw
issued twice. Trigger is material.transmission > 0 (:6890), set from the glTF at GLTFLoader:1202.
And at 1.0 it is not 'a bit glassy': :494 does totalDiffuse = mix(totalDiffuse, transmitted.rgb,
material.transmission) — the diffuse term is REPLACED by the backdrop.

I SCANNED ALL 103 GLBs. Three carried it:
  procity_fit_bookshelf_01      mtl_10218_Bookshelves_v1     shipped and hurting (C's)
  procity_street_longbench_01   lambert3SG                   NOT WIRED — the 5.86 m arcade bench
                                                             the v9 charter names, on my R38
                                                             recommend list for Lane B
  procity_street_streetlight_01 Streetlight_HighResSG3       NOT WIRED — one per ~18 m of street
The two unwired ones are the worse finding: the street budget is 292/300 with EIGHT draws of margin,
and a pre-pass there costs another ~292, not eight. Armed, not fired.

FIXED SURGICALLY. pipeline/strip_transmission.py rewrites only the JSON chunk and copies BIN through,
printing sha1(BIN) before and after: BIN IDENTICAL on all three, tris/dims/mtl/img/tex unchanged.
KHR_materials_ior deliberately left (ior does not trigger the pre-pass).

PUBLISHED + VERIFIED under John's R22 standing authorization via the passwordless tailnet ingress:
6/6 HTTP 200 off the PUBLIC depot, byte counts exact, sha1 identical, depot copies re-scanned clean.
_published.json 56 -> 59.

GATED, which matters more than the fix: validate_manifest.py now HARD FAILS on transmissionFactor>0
with the three.js line numbers and the fix command in the message; glb_stat.py grew a transmission
column and a loud footer. Nobody looked for three epochs because a bookshelf is obviously not glass.

ALSO: the arcade's three meshes, published and catalogued the same round they were generated —
aframe_board 366 tris (<=16 inst), blade_sign 299 (17 inst), keycutter_sign 498 (1 inst). manifest
furniture 13->16, GLBs 36->39, validate 0 errors 0 warnings. blade_sign ships FLAGGED: its back face
carries TRELLIS-hallucinated art where the concept had no data, which is one more reason the
+0-draw perpendicular-quad answer is the recommendation and this GLB is the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:11:34 +10:00

386 lines
23 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
# Round-38 wave-1 props. WIRED BUT NOT YET LIVE, deliberately: the three shipped GLBs (magpie,
# paling_fence, water_tank) are normalized, baked to budget and staged in _normalized/, but they
# have NOT been pushed to the 3GOD depot — that is an outward-facing publish and Lane E did not
# have in-session authorization for it this round. `validate_manifest.py` correctly FAILS any
# manifest GLB with no `_published.json` record, so the results file is held at
# `_r38_results.PENDING_PUBLISH.json`. ONE STEP lands them: publish, then rename it to
# `_r38_results.json` and re-run this script. See AUDIT.md R38 §4 and LANE_E_NOTES R38.
R38_RESULTS = os.path.join(ROOT, "pipeline", "_r38_results.json") # round-38 wave-1 props
# [R39] the arcade kit — aframe_board / blade_sign / keycutter_sign. All three are PUBLISHED and
# sha1-verified off the public depot, so they are catalogued the same round they were generated
# (contrast R38, which held its results file at `.PENDING_PUBLISH` until the push landed).
R39_RESULTS = os.path.join(ROOT, "pipeline", "_r39_results.json") # round-39 arcade kit
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
# ── R38 / v8 wave 1.1 — THE INHABITATION LAYER. Six point sources for Lane B's seeded anchors:
# key -> (category, gain, loop, anchor-hint, note). They land in the normal ambience/sfx groups so
# every existing consumer keeps working untouched, and each carries `life: true` — the same flag
# shape R12 used for `gig: true`. `anchor` is a HINT, not a contract: Lane B owns placement and is
# free to re-anchor. Gains are deliberately low because `gen_audio.encode()` runs loudnorm over
# every ambience asset, so "distant" cannot be shipped as a quiet render — it is shipped as
# spectrum in the synth, and as gain here.
AUDIO_LIFE = {
"mower-distant": ("ambience", 0.30, True, "town", "a mower two streets over"),
"radio-through-window": ("ambience", 0.25, True, "house", "someone's radio through a window"),
"sprinkler": ("ambience", 0.35, True, "yard", "impulse sprinkler on a lawn"),
"dog-bark-fence": ("sfx", 0.55, False, "yard", "the dog going off behind a paling fence"),
"roller-door": ("sfx", 0.50, False, "shop", "a roller door run up"),
"magpie-carol": ("sfx", 0.45, False, "tree", "the carol — the warble, not the clack"),
}
# ── 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
# ── R39 / v9 39.4 — the arcade's dead tenant. PLACED BY KEY, never drawn from a type pool:
# `shuttered` is deliberately absent from registry.js's SHOP_TYPES[*].facades, so no shop can
# roll it, and a shop that is OPEN must never wear a shutter. It is shot at 768x595 (1.29:1),
# the measured arcade lot aspect (mean frontage 3.87-4.05 m against FACADE_H 3.0), not the
# main-street pool's 1.78:1 — skins.js paints every facade into a SQUARE atlas slot, so a
# 1.78:1 source on a 1.29:1 quad is a 38% horizontal stretch. Costs +0 draws, +0 tris and one
# of the 14 FREE facade-atlas slots (the synthetic uses 22 of 36; skins.js:facadeAtlasUV warns
# and REUSES a slot past 36 — that is the real budget for a facade skin, not bytes).
"shuttered": ["general"],
# 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",
# ── R38 / v8 wave 1.2 — the per-town ground palette, so 23 towns can look like 23 towns.
# ground.js merges each surface class into ONE town-wide mesh, so a palette swap is +0 draws
# / +0 tris. NOTE FOR LANE B (measured, R38): ground.js:177 hardcodes exactly FOUR slots —
# road / footpath / yard (the base plane) / plaza. `gravel` and `reddust` are selected by
# nothing partly because their `use` values ("verge", "outback") name no slot that exists.
# These four deliberately use live slot vocabulary so the new selector can consume them
# without a vocabulary change.
"grass-dry": "yard", "grass-temperate": "yard", "coastal-sand": "yard",
"bitumen-reddust": "road",
# ── R39 / v9 39.4 — THE ARCADE'S TWO SURFACES. These two `use` values name slots that DO NOT
# EXIST YET, and unlike gravel→"verge" / reddust→"outback" (R38: selected by nothing, by
# accident) that is DELIBERATE and costed. The arcade cannot ride an existing slot: `footpath`
# is one town-wide merged mesh, so re-using it would lay terrazzo over every footpath in town.
# Each therefore needs its own class, and the price is stated:
# arcade-floor +1 draw TOWN-WIDE (ground.js builds once, not per chunk: a 6th merged mesh),
# +0 tris — the 42.00 x 12.00 m = 504 m2 lane quad MOVES out of footGeos.
# arcade-ceiling +1 draw in the 1-2 chunks that hold arcade lots, +2 tris (one 42 x 5 m quad).
# BOTH are synthetic-only by construction: plan_osm.js:305 is one unconditional
# addDistrict('mainstreet'), so no cache town has an arcade DISTRICT. Key on district.kind and
# never on edge.kind — the corpus has 1,102 arcade-KIND edges = 39 km of ordinary suburban
# footpath (v9 cut list). And both must be classic-gated: ?classic=1 does not move on dressing.
"arcade-floor": "arcade-floor", "arcade-roof": "arcade-ceiling",
}
# ── 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, (cat, g, lp, anchor, note) in AUDIO_LIFE.items(): # R38 inhabitation layer
r = ref(f"{cat}-{k}")
if not r:
continue
e = {**r, "gain": g, "scope": "street", "life": True, "anchor": anchor, "note": note}
if lp:
e["loop"] = True
(ambience if cat == "ambience" else sfx)[k] = e
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, R38_RESULTS, R39_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.",
"audio_life": "entries tagged life:true are the v8 wave-1.1 INHABITATION LAYER — point "
"sources for a life happening off-screen, played at a seeded anchor so it "
"is the same dog on the same street every time. `anchor` is a placement "
"HINT (town/house/yard/shop/tree), not a contract. playSfx is MONO with a "
"gain multiplier, so distance reads and direction does not: 'distance' is "
"baked into each asset's spectrum, not its level (loudnorm would undo a "
"quiet render). Zero draws, zero tris.",
},
"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()