diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md new file mode 100644 index 0000000..456f7a0 --- /dev/null +++ b/docs/LANES/LANE_D_NOTES.md @@ -0,0 +1,227 @@ +# LANE D — Assets — NOTES + +## Round 1 (2026-07-16) — pipeline online, first texture pack, audio proof + +Everything below was generated **free and local** on the m3ultra box (`flux_local`, MPS). +Total spend this round: **$0.00**. `FAL_LIST.md` does not exist yet — nothing needed it. + +### What landed + +| thing | where | note | +|---|---|---| +| 6 wall textures + normals | `web/assets/gen/wall_*.webp` | grayscale SEM, exact-tiling, 1024² | +| 1 wet-tissue matcap | `web/assets/gen/matcap_tissue_wet.webp` | 512², cropped to the ball | +| 1 bed + 4 sfx | `web/assets/audio/*.{ogg,m4a}` | procedural numpy synth, dual-shipped | +| manifest + loader | `web/assets/manifest.json`, `js/core/assets.js` | null-on-miss, `?localassets=0` | +| pipeline scripts | `pipeline/*.py` | ported from PROCITY, `--dry-run` first | +| dev viewer | `web/dev/laneD_texview.html` | in-engine eyeball harness (see §ownership) | +| shot sink | `pipeline/shot_sink.py` | writes canvas frames into `docs/shots/laneD/` | + +**Shipped asset weight: 2.3 MB** (1.7 MB textures+normals, 0.61 MB audio). Audio budget 0.61/10 MB. + +### Measured (not estimated) + +- **Generation:** 9.0–9.6 s per 1024² image on the m3ultra MPS GPU (`flux2-klein-4b`, 4 steps, + guidance 3.5, quantize 8), one GPU job at a time. Whole 7-image pack ≈ 64 s. Audio: **4.2 s** + for the entire pack (numpy synth + ffmpeg opus/aac), measured with `time`. +- **Tiling** — `seam_error` = mean |Δ| across the wrap seam ÷ mean |Δ| inside the image. + **~1.0 means the seam is indistinguishable from ordinary interior detail.** FLUX is *told* + "seamless tiling texture" and flatly ignores it — every raw image scored 3.7–17.9: + + | texture | seam before | seam after | mean before → after | + |---|---|---|---| + | wall_esophagus_a | 3.70 | **1.14** | 0.22 → 0.50 | + | wall_esophagus_b | 11.12 | **2.39** | 0.17 → 0.49 | + | wall_smallint_a | 8.60 | **1.04** | 0.18 → 0.49 | + | wall_smallint_b | 10.50 | **1.32** | 0.24 → 0.50 | + | wall_stomach_a | 12.16 | **0.87** | 0.22 → 0.50 | + | wall_stomach_b | 17.85 | **1.08** | 0.25 → 0.50 | + + `wall_esophagus_b` (2.39) is the pack's weakest: it's near-pure parallel lines, so the blend + has no noise to hide in. It reads clean in the 2×2 check and in engine — flagging it honestly + rather than claiming 1.0. +- **Loop seam** (`bed-esophagus`) = **0.32**: the wrap step is 3× *smaller* than the average + sample-to-sample step, i.e. inaudible. Numeric, in `pipeline/batch_audio.json`. +- **Audio streams verified with ffprobe:** every asset ships opus/ogg 48 kHz stereo **and** + aac/m4a 44.1 kHz stereo. Final ear-check is John's (PIPELINE.md). + +Evidence: `docs/shots/laneD/` — `contact_walls.png` (source | 2×2 tile check | normal, per +texture), `contact_matcaps.png`, `audio_spectrograms.png` (log-freq, 80 dB, loop seam printed), +and 7 in-engine 1280×720 frames `round1_wall_*.png` + `round1_fallback_localassets0.png`. + +### Two things that were wrong and are now right + +1. **The pack was two stops apart.** FLUX gives every prompt its own exposure — source means + ranged 0.18–0.32. These are DETAIL maps *multiplied into a biome tint*, so a dark texture + doesn't read as "dark tissue", it reads as "this biome's tint is broken". `derive_maps.py` + now normalises every wall to mean 0.50 / std 0.19 (soft-clipped). The tint, not the texture, + decides brightness. +2. **The prompt kit needed framing, not uniformity** — see §prompt-kit below. + +### §prompt-kit — an ART_BIBLE amendment, tried and REJECTED + +The ART_BIBLE stem nails the *look* but lets FLUX compose a single centred hero subject: the +first `esophagus_b` was one sphincter donut (tiled → polka dots) and `smallint_a` was one +urchin of villi (tiled → mush). PROCITY hit this exact problem and solved it by ending every +template with *"filling the entire frame edge to edge, nothing else visible"*. + +I ported that into the STEM — and **it made the pack worse**: `esophagus_a` lost its ribbed +drama for bland wood-grain, and stomach/smallint collapsed into near-identical honeycomb. +**Uniformity is not the goal; variety across biomes is.** Rejected and reverted. + +What shipped instead: the **stem stays ART_BIBLE-verbatim**, and only the two prompts that +actually drew a hero subject carry framing words in their own subject text. Four winners were +kept untouched rather than churned. This is recorded in `gen_textures.py` so nobody re-runs the +experiment. **No ART_BIBLE edit is requested** — the file is right as written. + +### §tiling — convention (ratified with Lane A) + +`tile: [repeats_around_theta, units_of_s_per_repeat]`, read off the stub's +`uv = (theta/2pi, s-in-world-units)`. Lane A converged on the identical semantics independently +(LANE_A_NOTES §→ Lane D) — **ratified from my side too, it's settled.** `assets.texture(n)` +returns `repeat = [tile[0], 1/tile[1]]` precomputed. + +Per-texture values live in `pipeline/batch_textures.json` and flow into the manifest. + +## → Lane A + +Thanks for the review — answering each point: + +- **Naming (`wall_smallint_a` vs biome id `small_intestine`): agreed, your proposal, round 2.** + You're right that it's the dangerous kind of bug. I did **not** rename this round on purpose: + your `index.js` slug map is live and a unilateral rename would silently break it mid-round — + exactly the failure you described. Round 2, in one step: I rename to biome ids + (`wall_small_intestine_a`, `wall_large_intestine_a`), you delete the map. F to sequence. +- **Meanwhile the silent-miss class is fixed at my end.** `assets.js` now keeps a miss ledger: + the first time any name misses it logs `[assets] miss: textures/x — using fallback`, and + `assets.misses()` returns every name asked for that wasn't there. Verified against your exact + case: `assets.texture('wall_small_intestine_a')` → null + one log + listed in `misses()`. + So a drifted slug now announces itself instead of falling back forever in silence. +- **`texture()` vs `get()`: agreed** — `get()` is the documented floor (raw JSON, per TECH), + `texture()` is what a shader can eat. Seconding your suggestion that F promotes `texture()` + into TECH as the contract. I'll keep both, and `get()` keeps working exactly as specced. +- **"the `repeat` you pre-apply is inert in a raw ShaderMaterial" — correct, and it's deliberate.** + `texture.repeat` only feeds `uvTransform` in three's built-in materials. I set it anyway so + the value is right if anyone ever uses a built-in material, and I expose the same numbers as + `.repeat`/`.tile` for shader use. Multiply it in yourself: `vec2 duv = vUv * uRepeat;`. + +### The TBN gotcha — you said round 2, here's the answer now + +You noted the `_b` variants and matcap "aren't wired yet (round 2, with the TBN work)". I hit +the normal-map integration in the viewer and it cost me a while, so take the result: + +**Do NOT add the normal-map sample to the vertex normal.** I first wrote +`n = normalize(vN + tn * 0.55)`. A tangent-space sample is ~`(0,0,1)`, so adding it tilts every +normal toward the camera → `dot(n, view) → 1` → the **fresnel rim dies** → the tube renders +near-black. The rim *is* the biome's main light; this mistake looks like "Lane D's textures are +too dark" and it is not. Diagnosis: flip to the procedural fallback — if that's bright and the +textured path is black, it's this. + +The tube geometry carries no tangent attribute, so rebuild the TBN per-pixel from screen +derivatives (three's `perturbNormal2Arb`). Working, verified in +`web/dev/laneD_texview.html` — lift it: + +```glsl +// needs: new THREE.ShaderMaterial({ extensions: { derivatives: true }, ... }) +vec3 perturb(vec3 N, vec3 viewPos, vec2 st, vec3 mapN) { + vec3 q0 = dFdx(viewPos), q1 = dFdy(viewPos); + vec2 st0 = dFdx(st), st1 = dFdy(st); + vec3 S = normalize(q0 * st1.t - q1 * st0.t); + vec3 T = normalize(-q0 * st1.s + q1 * st0.s); + return normalize(mat3(S, T, N) * mapN); +} +// usage, in the fragment shader: +vec2 duv = vUv * uRepeat; // uRepeat = assets.texture(n).repeat +vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0; +tn.xy *= uNormalScale; // 0.6 looks right; 0 = off, 1 = full relief +vec3 n = perturb(normalize(vN), -vView, duv, normalize(tn)); +float fres = pow(1.0 - abs(dot(n, normalize(vView))), 2.2); +``` + +With this, the rim light traces every fold edge and the walls finally look like the ART_BIBLE +line ("SEM tissue + artificial rim light"): `round1_wall_esophagus_a.png`. It's the single +biggest visual win of my round and it's yours to take. + +- **Detail sampling**: the wall webp is a luminance/AO map. `float detail = texture2D(uDetail, + duv).r;` then `base = uTint * (0.35 + detail * 0.95) * 0.9` is what the shots use. Note + three uploads these as `SRGB8_ALPHA8` (I set `colorSpace = SRGBColorSpace`), so the GPU + decodes to linear on sample — that's intended. Normals are `NoColorSpace`, don't change that. +- **Biome tints beyond esophagus**: the viewer carries the ART_BIBLE palettes for stomach and + small intestine, so each wall was judged under its own tint, not teal. + +## → Lane F + +**1. Wiring `assets` into boot.** `assets.js` is done and `flags.localassets` already parses +(thank you). Nothing constructs it yet. Exact snippet to paste into `js/boot.js`: + +```js +import { createAssets } from './core/assets.js'; // with the other core imports + +const assets = await createAssets({ flags, renderer }); // after `renderer` exists, + // before pickWorld() +``` +then pass it into the lane factories (`createWorld(level, { rng, assets })`, and B/E likewise), +and add to the DBG export if you like: `assets` (has `.misses()` — see → Lane A). +It never throws and never blocks: no manifest ⇒ empty ⇒ every lane's fallback path. Safe to +wire before any lane consumes it. Lane A currently reaches assets via its own path; once boot +passes it in, that can collapse. + +**2. Ownership: I created `web/dev/`** — `laneD_texview.html`, a dev-only harness (nothing +imports it, not shipped). The eyeball law needs my textures on the real stub tube, but boot +doesn't construct `assets` yet, so it imports `world_stub.js` **read-only** and puts a textured +material on its geometry. Claiming `web/dev/laneD_texview.html` as Lane D; **please ratify or +relocate** in ROUND2. It doubles as the reference implementation Lane A is welcome to lift. + +**3. TECH suggestion (seconding Lane A):** promote `assets.texture(name)` into the asset +contract next to `get()`. `get()` returns raw JSON; `texture()` returns +`{map, normalMap, tile, repeat}` and is what consumers actually want. Also worth adding +`?sink=` nowhere near the flags table — it's viewer-local, not a game flag. + +**4. `tools/qa.sh` gate 5 already calls `validate_manifest.py`** and it's green: 6 textures, +1 matcap, 1 bed, 4 sfx, audio 0.61/10 MB. It enforces urls-exist, WebP ≤1024, matcap square, +sane `tile`, ogg+m4a dual-ship, and warns on orphans in `gen/`. An absent manifest is treated +as **legal** (asset-free boot), not an error. + +## → Lane E + +The audio pack is live and the loader is ready for your engine: + +```js +const url = assets.audioUrl('beds', 'esophagus'); // best format for THIS browser, or null +const e = assets.audio('sfx', 'pellet'); // {ogg, m4a, loop, gain, seconds} | null +``` +- **Keys** (not filenames): beds → `esophagus`. sfx → `pellet`, `hit_squelch`, `boost`, + `pickup`. `assets.list('textures')` etc. enumerates what actually shipped. +- **`audioUrl` probes the browser** and returns m4a where Opus-in-Ogg isn't playable (Safari). + Both formats ship for every asset — always. Don't hardcode `.ogg`. +- Each entry carries **`gain`** (bed 0.5; sfx 0.5–0.7) and **`loop`**. Please respect `gain` — + beds are loudnorm'd to −16 LUFS, sfx are peak-normalised to ≈−3.5 dBFS, so raw playback has + sfx much hotter than the bed by design. +- **`bed-esophagus` is exactly 24.000 s and loops seamlessly** (tail crossfaded over the head, + seam 0.32). Loop it with `audio.loop = true` / a looping AudioBufferSourceNode — no gap + logic, no fade needed. The heartbeat is 50 bpm and divides the loop exactly. +- Your procedural fallback beeps stay necessary: `?localassets=0` must still make noise. +- Round 2 = full pack (beds per biome, torpedo/gate/alarm, boss stingers). Tell me what names + you want and I'll match your keys rather than you adapting to mine. + +## → Lane C + +Got your round-2 list (`bolus_chunk`, `eosinophil_swarm`, `candida_bloom`) and the boss specs — +they're enough to concept from, thank you. Your esophagus wall pack shipped this round. +Early flag: **`candida_bloom` is the right call as a decal/plane, not a GLB** — a flat colony +on a curved wall is the shape TRELLIS is worst at, and a wall-hugging plaque wants to follow +`(s, theta)` anyway. I'll concept it as a sprite/decal sheet unless you object. +The tapeworm (long, thin, segmented) is my expected **fal.ai candidate** — PIPELINE flags thin +geometry as TRELLIS's known weakness. It'd go on `FAL_LIST.md` for John's approval, ~30¢. + +## Open questions / round 2 + +1. **Slug rename** (§→ Lane A) — needs F to sequence it with A's map deletion. One step, both files. +2. **Hero meshes** — the round-2 headline: ENDO-1 ship + C's three enemies. concept → sf3d + draft → trellis_mac → normalize → thumb. Blobby organics are TRELLIS's sweet spot; the + tapeworm probably isn't. +3. **Colon + mouth walls** (2 each) to finish the biome set, + acid/decal sprites. +4. **Full audio pack** — pending E's key list. +5. `normalize.py` + `glb_stat.py` still need porting from PROCITY (round 2, with the meshes) — + the house GLB law (metres, +Y up, ≤5k tris, no Draco) is unenforced until they exist. + `validate_manifest.py` already fails any model over 5k tris that declares its count. diff --git a/docs/progress/d-progress.md b/docs/progress/d-progress.md new file mode 100644 index 0000000..7815802 --- /dev/null +++ b/docs/progress/d-progress.md @@ -0,0 +1,58 @@ +# Lane D — progress log + +## 2026-07-16 — Round 1: pipeline online, first texture pack, audio proof + +Shipped all four round-1 items. **$0.00 spent** — everything generated locally on the m3ultra +box; no fal.ai candidates arose, so `FAL_LIST.md` still doesn't exist. + +**1. Scripts ported from PROCITY** (`--dry-run` first, idempotent, resumable): +`gen_textures.py` (flux_local backend, provenance JSON), `derive_maps.py` (tiling + Sobel +normals + WebP), `build_manifest.py`, `validate_manifest.py` (qa gate 5, stdlib-only so it runs +on any box — hand-parses the WebP header rather than needing PIL), `gen_audio.py`. +Plus `shot_sink.py`, a small addition of my own: `DBG.shot()`'s `` lands as +unnamed half-written temp blobs in this browser pane, and naming evidence by guessing at file +sizes isn't evidence — so the page POSTs frames and they land correctly named. + +**2. First texture pack** — 6 walls (2 each: esophagus / stomach / small intestine) + normals ++ 1 wet-tissue matcap. 9.0–9.6 s per 1024² image, ~64 s for the pack. 1.7 MB shipped. +- Every raw FLUX image had a hard wrap seam (seam_error 3.7–17.9) despite the prompt asking for + "seamless tiling texture". A 4-way cosine partition-of-unity blend of the four half-rolls + fixes it *exactly* (weights sum to 1, are N-periodic, and each copy's seam sits where its own + weight is 0) → **0.87–1.32**, i.e. the seam is now indistinguishable from interior detail. +- Levels-normalised the pack to a common exposure: FLUX gave each prompt its own brightness + (means 0.18–0.32), which under a multiplied biome tint reads as a broken tint, not dark tissue. +- Prompt-kit experiment **rejected**: adding PROCITY's "uniform coverage / edge to edge" clause + to the ART_BIBLE stem cured the centred-subject failures but flattened the whole pack and + collapsed three biomes into the same honeycomb. Reverted; framing words now live only in the + two subject prompts that needed them. Recorded in `gen_textures.py` so it isn't re-tried. + +**3. `js/core/assets.js`** — manifest loader per the TECH contract: null-on-miss, never throws, +`?localassets=0` empty boot, `depot:` resolution, dual-format audio probing. Added a **miss +ledger** after Lane A identified the silent-forever-fallback failure mode: each distinct miss +logs once and `assets.misses()` lists them. + +**4. Audio proof** — `bed-esophagus` (24.000 s, loop seam **0.32** = inaudible) + 4 sfx +(pellet, hit-squelch, boost, pickup). Whole pack renders in **4.2 s**. 0.61/10 MB budget, +ogg+m4a dual-shipped, verified with ffprobe. +- First spectrogram sheet was a saturated cream rectangle (fixed `(db+60)/70` normalisation) — + evidence you can't read is not evidence. Rewrote against each clip's own peak over 80 dB with + a log frequency axis and Hz gridlines. +- Caught by looking at it: the bed had strong energy to 16 kHz — my `lp1` is one-pole (6 dB/oct), + so a 900 Hz lowpass left 16 kHz only ~25 dB down. That's hiss, not a wet gut. Added cascaded + poles; the bed is now properly muffled below ~1 kHz. + +**Eyeballed in engine** (`web/dev/laneD_texview.html`, a dev harness that imports the stub world +read-only): all 6 walls under their own ART_BIBLE biome tints + the `?localassets=0` fallback → +`docs/shots/laneD/round1_*.png`. +- **The find of the round:** normal maps were rendering the tube near-black. I'd added the + tangent-space sample to the view-space normal, which tilts every normal at the camera and + kills the fresnel rim — and the rim is the biome's only real light. Rebuilt the TBN per-pixel + from screen derivatives; the rim now traces every fold edge and the walls finally look like + the ART_BIBLE's "SEM tissue + artificial rim light". Handed the working code to Lane A in + NOTES, since they'd scheduled TBN work for round 2. + +QA green. Committed only Lane D paths. + +**Next round:** slug rename to biome ids (with A deleting their map — F to sequence), hero +meshes (ENDO-1 + C's three enemies), colon/mouth walls, full audio pack, port `normalize.py` + +`glb_stat.py` so the house GLB law is actually enforced. diff --git a/docs/shots/laneD/audio_spectrograms.png b/docs/shots/laneD/audio_spectrograms.png new file mode 100644 index 0000000..9ce502f Binary files /dev/null and b/docs/shots/laneD/audio_spectrograms.png differ diff --git a/docs/shots/laneD/contact_matcaps.png b/docs/shots/laneD/contact_matcaps.png new file mode 100644 index 0000000..6fe5aed Binary files /dev/null and b/docs/shots/laneD/contact_matcaps.png differ diff --git a/docs/shots/laneD/contact_walls.png b/docs/shots/laneD/contact_walls.png new file mode 100644 index 0000000..0c311aa Binary files /dev/null and b/docs/shots/laneD/contact_walls.png differ diff --git a/docs/shots/laneD/round1_fallback_localassets0.png b/docs/shots/laneD/round1_fallback_localassets0.png new file mode 100644 index 0000000..8e7a6f8 Binary files /dev/null and b/docs/shots/laneD/round1_fallback_localassets0.png differ diff --git a/docs/shots/laneD/round1_wall_esophagus_a.png b/docs/shots/laneD/round1_wall_esophagus_a.png new file mode 100644 index 0000000..226a680 Binary files /dev/null and b/docs/shots/laneD/round1_wall_esophagus_a.png differ diff --git a/docs/shots/laneD/round1_wall_esophagus_b.png b/docs/shots/laneD/round1_wall_esophagus_b.png new file mode 100644 index 0000000..f2a9b0c Binary files /dev/null and b/docs/shots/laneD/round1_wall_esophagus_b.png differ diff --git a/docs/shots/laneD/round1_wall_smallint_a.png b/docs/shots/laneD/round1_wall_smallint_a.png new file mode 100644 index 0000000..eb6a99a Binary files /dev/null and b/docs/shots/laneD/round1_wall_smallint_a.png differ diff --git a/docs/shots/laneD/round1_wall_smallint_b.png b/docs/shots/laneD/round1_wall_smallint_b.png new file mode 100644 index 0000000..00301e8 Binary files /dev/null and b/docs/shots/laneD/round1_wall_smallint_b.png differ diff --git a/docs/shots/laneD/round1_wall_stomach_a.png b/docs/shots/laneD/round1_wall_stomach_a.png new file mode 100644 index 0000000..02ed0e9 Binary files /dev/null and b/docs/shots/laneD/round1_wall_stomach_a.png differ diff --git a/docs/shots/laneD/round1_wall_stomach_b.png b/docs/shots/laneD/round1_wall_stomach_b.png new file mode 100644 index 0000000..3d5d0ac Binary files /dev/null and b/docs/shots/laneD/round1_wall_stomach_b.png differ diff --git a/pipeline/batch_audio.json b/pipeline/batch_audio.json new file mode 100644 index 0000000..5a8a885 --- /dev/null +++ b/pipeline/batch_audio.json @@ -0,0 +1,48 @@ +{ + "bed-esophagus": { + "category": "bed", + "gain": 0.5, + "key": "esophagus", + "loop": true, + "loop_seam": 0.319, + "m4a_kb": 380, + "ogg_kb": 164, + "seconds": 24.0 + }, + "sfx-boost": { + "category": "sfx", + "gain": 0.6, + "key": "boost", + "loop": false, + "m4a_kb": 14, + "ogg_kb": 6, + "seconds": 0.85 + }, + "sfx-hit-squelch": { + "category": "sfx", + "gain": 0.7, + "key": "hit_squelch", + "loop": false, + "m4a_kb": 6, + "ogg_kb": 2, + "seconds": 0.34 + }, + "sfx-pellet": { + "category": "sfx", + "gain": 0.5, + "key": "pellet", + "loop": false, + "m4a_kb": 4, + "ogg_kb": 2, + "seconds": 0.2 + }, + "sfx-pickup": { + "category": "sfx", + "gain": 0.5, + "key": "pickup", + "loop": false, + "m4a_kb": 9, + "ogg_kb": 6, + "seconds": 0.5 + } +} \ No newline at end of file diff --git a/pipeline/batch_textures.json b/pipeline/batch_textures.json new file mode 100644 index 0000000..d5684e8 --- /dev/null +++ b/pipeline/batch_textures.json @@ -0,0 +1,141 @@ +{ + "matcap_tissue_wet": { + "gen_seconds": 8.9, + "guidance": 3.5, + "kb": 19, + "kind": "matcap", + "model": "flux2-klein-4b", + "prompt": "spherical material study ball, wet translucent organic tissue, subsurface glow, studio black background", + "seam": null, + "seed": 1401, + "size": 512, + "steps": 4, + "tile": null + }, + "wall_esophagus_a": { + "gen_seconds": 9.1, + "guidance": 3.5, + "kb": 92, + "kb_n": 220, + "kind": "wall", + "mean_after": 0.495, + "mean_before": 0.216, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, human esophageal mucosa, longitudinal ribbed folds, wet ridges", + "seam_after": 1.14, + "seam_before": 3.7, + "seed": 1101, + "size": 1024, + "steps": 4, + "tile": [ + 4, + 16 + ], + "tile_mode": "blend" + }, + "wall_esophagus_b": { + "gen_seconds": 9.2, + "guidance": 3.5, + "kb": 47, + "kb_n": 159, + "kind": "wall", + "mean_after": 0.492, + "mean_before": 0.173, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, human esophageal mucosa, rows of transverse circular ridges repeating across the whole frame, wet muscular bands, filling the entire frame edge to edge", + "seam_after": 2.39, + "seam_before": 11.12, + "seed": 1102, + "size": 1024, + "steps": 4, + "tile": [ + 3, + 12 + ], + "tile_mode": "blend" + }, + "wall_smallint_a": { + "gen_seconds": 9.2, + "guidance": 3.5, + "kb": 90, + "kb_n": 204, + "kind": "wall", + "mean_after": 0.495, + "mean_before": 0.185, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, dense field of intestinal villi, soft finger-like projections packed across the whole frame, filling the entire frame edge to edge", + "seam_after": 1.04, + "seam_before": 8.6, + "seed": 1301, + "size": 1024, + "steps": 4, + "tile": [ + 5, + 14 + ], + "tile_mode": "blend" + }, + "wall_smallint_b": { + "gen_seconds": 9.2, + "guidance": 3.5, + "kb": 106, + "kb_n": 216, + "kind": "wall", + "mean_after": 0.496, + "mean_before": 0.24, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, intestinal crypts of Lieberkuhn, honeycomb of round pits, packed glandular openings", + "seam_after": 1.32, + "seam_before": 10.5, + "seed": 1302, + "size": 1024, + "steps": 4, + "tile": [ + 6, + 10 + ], + "tile_mode": "blend" + }, + "wall_stomach_a": { + "gen_seconds": 9.1, + "guidance": 3.5, + "kb": 73, + "kb_n": 193, + "kind": "wall", + "mean_after": 0.496, + "mean_before": 0.216, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, gastric mucosa, pitted craters, gastric pits, glistening mucus", + "seam_after": 0.87, + "seam_before": 12.16, + "seed": 1201, + "size": 1024, + "steps": 4, + "tile": [ + 4, + 18 + ], + "tile_mode": "blend" + }, + "wall_stomach_b": { + "gen_seconds": 9.2, + "guidance": 3.5, + "kb": 62, + "kb_n": 179, + "kind": "wall", + "mean_after": 0.497, + "mean_before": 0.25, + "model": "flux2-klein-4b", + "prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, stomach rugae, thick heavy folded ridges, deep valleys between folds", + "seam_after": 1.08, + "seam_before": 17.85, + "seed": 1202, + "size": 1024, + "steps": 4, + "tile": [ + 3, + 22 + ], + "tile_mode": "blend" + } +} \ No newline at end of file diff --git a/pipeline/derive_maps.py b/pipeline/derive_maps.py index ba806be..3c7e073 100644 --- a/pipeline/derive_maps.py +++ b/pipeline/derive_maps.py @@ -37,6 +37,14 @@ NORMAL_STRENGTH = 2.4 OUT_SIZE = 1024 # house law: WebP <= 1024 (TECH.md) MATCAP_SIZE = 512 +# Pack coherence: FLUX gives every prompt its own exposure (the esophagus came back ~2 stops +# darker than the villi). These are DETAIL maps that get MULTIPLIED into a biome tint by Lane +# A's shader, so a dark texture doesn't read as "dark tissue", it reads as "this biome's tint +# is broken". Normalising every wall to the same mean/spread makes the tint mean the same +# thing in every biome; the shader, not the texture, decides brightness. +LEVELS_MEAN = 0.50 +LEVELS_STD = 0.19 + # ── image <-> array ───────────────────────────────────────────────────────────────────── def load_gray(path): @@ -95,6 +103,14 @@ def match_stats(x, ref): return np.clip((x - x.mean()) * (ref.std() / sx) + ref.mean(), 0.0, 1.0) +def normalize_levels(a, mean=LEVELS_MEAN, std=LEVELS_STD): + """Bring a wall to the pack's common exposure. Linear (shape-preserving) — soft-clipped + at the ends so the SEM speckle highlights roll off instead of blowing out to flat white.""" + sa = a.std() or 1.0 + x = (a - a.mean()) * (std / sa) + mean + return np.clip(0.5 + np.tanh((x - 0.5) * 1.9) * 0.5, 0.0, 1.0) + + def seam_error(a): """Mean |delta| across the wrap seams vs mean |delta| inside the image. ~1.0 = tiles perfectly (the seam is as smooth as ordinary interior detail); >>1 = visible seam. @@ -191,7 +207,9 @@ def harvest(only="", tile_mode="blend", contact_only=False): continue before = seam_error(a) + mean_before = float(a.mean()) tiled = {"blend": tile_blend, "mirror": tile_mirror, "none": lambda z: z}[tile_mode](a) + tiled = normalize_levels(tiled) if tiled.shape[0] != OUT_SIZE: tiled = resize(tiled, OUT_SIZE) after = seam_error(tiled) @@ -203,8 +221,10 @@ def harvest(only="", tile_mode="blend", contact_only=False): wall_rows.append((slug, a, tiled, nrm)) results[slug] = dict(kind="wall", seam_before=round(float(before), 2), seam_after=round(float(after), 2), kb=kb_a, kb_n=kb_n, + mean_before=round(mean_before, 3), mean_after=round(float(tiled.mean()), 3), size=OUT_SIZE, tile_mode=tile_mode) - print(f" {slug:22s} seam {before:5.2f} -> {after:4.2f} " + print(f" {slug:22s} seam {before:5.2f} -> {after:4.2f} " + f"mean {mean_before:.2f} -> {tiled.mean():.2f} " f"{kb_a:>4}KB +{kb_n:>4}KB normal [{tile_mode}]") for slug, r in results.items(): diff --git a/pipeline/gen_audio.py b/pipeline/gen_audio.py index 61a8345..99ffe35 100644 --- a/pipeline/gen_audio.py +++ b/pipeline/gen_audio.py @@ -101,12 +101,21 @@ def lp1(x, cutoff): return lp_pole(x * (1 - a), a) +def lp(x, cutoff, poles=1): + """Cascaded one-poles: 6 dB/oct each. ONE pole is not "muffled" — at cutoff 900 it still + leaves 16 kHz only ~25 dB down, which reads as hiss, not as a wet cavern. Anything that + is supposed to sound muffled wants 2–3 poles.""" + for _ in range(poles): + x = lp1(x, cutoff) + return x + + def hp1(x, cutoff): return x - lp1(x, cutoff) -def bandpass(x, lo, hi): - return hp1(lp1(x, hi), lo) +def bandpass(x, lo, hi, poles=1): + return hp1(lp(x, hi, poles), lo) def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1): @@ -211,10 +220,11 @@ def bed_esophagus(name): # low wet drone — two detuned saws, heavily lowpassed, slow swell d1 = osc(55.0, dur, "saw") + osc(55.0 * 1.006, dur, "saw") * 0.8 - drone = lp1(d1 * 0.25, 190) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0)) + drone = lp(d1 * 0.25, 190, poles=2) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0)) - # cavern air — pink noise in the 200–900 band, breathing at 0.25 Hz - air = bandpass(noise(dur, r, "pink"), 200, 900) * 0.30 + # cavern air — pink noise in the 200–900 band, breathing at 0.25 Hz. 3 poles: you are + # inside a body, everything above ~1 kHz is behind wet tissue. + air = bandpass(noise(dur, r, "pink"), 200, 900, poles=3) * 0.30 air *= 0.6 + 0.4 * np.sin(2 * np.pi * tarr(dur) / 8.0) # muffled heartbeat (lub-dub), 50 bpm @@ -238,8 +248,7 @@ def bed_esophagus(name): st = int(i * 4.0 * SR) wd = 1.8 seg = noise(wd, np.random.default_rng(seed_of(name) + i)) - sweep = np.linspace(300, 1600, int(wd * SR)) - seg = bandpass(seg, 200, 2000) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2 + seg = bandpass(seg, 200, 1400, poles=2) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2 seg *= 0.18 * (0.7 + 0.3 * math.sin(i)) n = min(len(seg), len(wh) - st) if n > 0: @@ -343,23 +352,39 @@ def loop_seam(x): return float(wrap / inner) +SPEC_TICKS = (50, 100, 500, 1000, 5000, 10000) # Hz gridlines drawn on the sheet +SPEC_FMIN, SPEC_FMAX, SPEC_DB = 40.0, 16000.0, 80.0 + + +def _log_rows(nbins, h): + """Row -> FFT-bin map for a LOG frequency axis. A linear axis spends 3/4 of the image on + 8–16 kHz where these sounds have nothing; log puts the drone, the heartbeat and the + squelch where you can actually see them.""" + freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, h) + return np.clip((freqs / (SR / 2) * (nbins - 1)).astype(int), 0, nbins - 1) + + def spectrogram(x, w=420, h=180): - """|STFT| -> log-magnitude image column-per-frame. numpy only (no matplotlib on the box).""" + """|STFT| -> log-magnitude, log-frequency image. numpy only (no matplotlib on the box). + + Levels are normalised to EACH clip's own peak over an 80 dB range. (First cut used a + fixed `(db+60)/70` and every sheet came out a saturated cream rectangle — evidence you + can't read is not evidence.)""" m = x.mean(axis=1) if x.ndim == 2 else x n = 1024 hop = max(1, (len(m) - n) // w) win = np.hanning(n) cols = [] for i in range(w): - st = i * hop - seg = m[st:st + n] + seg = m[i * hop:i * hop + n] if len(seg) < n: seg = np.pad(seg, (0, n - len(seg))) - S = np.abs(np.fft.rfft(seg * win)) - cols.append(20 * np.log10(S + 1e-6)) - img = np.array(cols).T[:h * 2:2][::-1] # low freqs at the bottom - img = np.clip((img + 60) / 70, 0, 1) - return img + cols.append(np.abs(np.fft.rfft(seg * win))) + S = np.array(cols).T # (bins, frames) + db = 20 * np.log10(S + 1e-9) + db -= db.max() # 0 dB = this clip's loudest bin + img = db[_log_rows(S.shape[0], h)][::-1] # low freqs at the bottom + return np.clip((img + SPEC_DB) / SPEC_DB, 0, 1) def evidence(rendered): @@ -368,19 +393,25 @@ def evidence(rendered): from PIL import Image, ImageDraw rows = len(rendered) W, H, PADL = 420, 180, 150 - sheet = Image.new("RGB", (W + PADL + 20, rows * (H + 14) + 14), (10, 12, 14)) + sheet = Image.new("RGB", (W + PADL + 56, rows * (H + 16) + 16), (10, 12, 14)) d = ImageDraw.Draw(sheet) + freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, H) for i, (name, x, meta) in enumerate(rendered): - y = 8 + i * (H + 14) + y = 8 + i * (H + 16) img = spectrogram(x, W, H) g = (img * 255).astype(np.uint8) rgb = np.stack([g, (g * 0.85).astype(np.uint8), (g * 0.55).astype(np.uint8)], axis=-1) sheet.paste(Image.fromarray(rgb, "RGB"), (PADL, y)) + for f in SPEC_TICKS: # log-axis gridlines + Hz labels + row = H - 1 - int(np.argmin(np.abs(freqs - f))) + d.line([(PADL, y + row), (PADL + W, y + row)], fill=(60, 90, 100)) + d.text((PADL + W + 4, y + row - 5), + f"{f//1000}k" if f >= 1000 else str(f), fill=(110, 140, 150)) d.text((8, y + 6), name, fill=(90, 255, 210)) - d.text((8, y + 22), f"{meta['seconds']:.2f}s", fill=(150, 170, 180)) + d.text((8, y + 24), f"{meta['seconds']:.2f}s {meta['ogg_kb']}KB ogg", fill=(150, 170, 180)) if meta.get("loop"): - d.text((8, y + 38), f"seam {meta['loop_seam']:.2f}", fill=(150, 170, 180)) - d.text((8, y + 54 if meta.get("loop") else y + 38), f"{meta['ogg_kb']}KB ogg", fill=(150, 170, 180)) + d.text((8, y + 40), f"loop seam {meta['loop_seam']:.2f}", fill=(150, 170, 180)) + d.text((8, y + H - 14), "40Hz–16kHz log · 80dB", fill=(90, 110, 120)) p = os.path.join(SHOTS, "audio_spectrograms.png") sheet.save(p, "PNG") return p diff --git a/pipeline/gen_textures.py b/pipeline/gen_textures.py index ee69a41..5d79d87 100644 --- a/pipeline/gen_textures.py +++ b/pipeline/gen_textures.py @@ -29,7 +29,13 @@ MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST")) FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python") FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py") -# ── the style stem — verbatim from ART_BIBLE §FLUX prompt kit ──────────────────────────── +# ── the style stem — verbatim from ART_BIBLE §FLUX prompt kit. DO NOT add framing words ── +# Tried and rejected (round 1, Lane D — see LANE_D_NOTES §prompt-kit): appending PROCITY's +# "uniform coverage, no single central subject, filling the frame edge to edge" to the STEM. +# It cured the centred-subject failures but flattened the whole pack — esophagus_a lost its +# ribbed drama for bland wood-grain, and stomach/smallint collapsed into the same honeycomb. +# Uniformity is not the goal; VARIETY across biomes is. So the stem stays pure style, and the +# two prompts that actually drew a hero subject carry their own framing words instead. STEM = ("scanning electron microscope micrograph, monochrome, high detail organic tissue, " "dark background, dramatic rim lighting, seamless tiling texture") @@ -40,8 +46,11 @@ WALLS = { "wall_esophagus_a": dict( p="human esophageal mucosa, longitudinal ribbed folds, wet ridges", seed=1101, tile=[4, 16]), + # "circular muscular rings" drew ONE ring, centred -> polka dots when tiled. Ask for the + # FIELD, not the feature, and carry the framing clause in the subject. "wall_esophagus_b": dict( - p="human esophagus inner lining, tight circular muscular rings, contracted sphincter folds", + p="human esophageal mucosa, rows of transverse circular ridges repeating across the " + "whole frame, wet muscular bands, filling the entire frame edge to edge", seed=1102, tile=[3, 12]), "wall_stomach_a": dict( p="gastric mucosa, pitted craters, gastric pits, glistening mucus", @@ -49,8 +58,11 @@ WALLS = { "wall_stomach_b": dict( p="stomach rugae, thick heavy folded ridges, deep valleys between folds", seed=1202, tile=[3, 22]), + # "forest of fronds" drew one radial urchin burst. Same fix; keep "finger-like" so this + # stays VILLI and doesn't drift into smallint_b's honeycomb crypts. "wall_smallint_a": dict( - p="intestinal villi, dense finger-like projections, forest of fronds", + p="dense field of intestinal villi, soft finger-like projections packed across the " + "whole frame, filling the entire frame edge to edge", seed=1301, tile=[5, 14]), "wall_smallint_b": dict( p="intestinal crypts of Lieberkuhn, honeycomb of round pits, packed glandular openings", diff --git a/pipeline/shot_sink.py b/pipeline/shot_sink.py new file mode 100644 index 0000000..b703bdf --- /dev/null +++ b/pipeline/shot_sink.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Lane D shot sink — write canvas frames from a dev page straight into docs/shots/laneD/. + +Why: the eyeball law wants in-engine PNGs committed as evidence every round, but `DBG.shot()` +relies on an click, and in a headless/embedded browser pane those land as unnamed +half-written temp blobs in ~/Downloads. Naming evidence by guessing at file sizes is not +evidence. So: the page POSTs the canvas here and the file lands with the right name, first try. + + python3 pipeline/shot_sink.py & # listens on :8141, writes docs/shots/laneD/ + python3 pipeline/shot_sink.py --dir docs/shots/laneD --port 8141 + +From the page (any origin — CORS is wide open, it's a localhost dev tool): + + canvas.toBlob(b => fetch('http://localhost:8141/shot/my_name.png', {method:'POST', body:b})); + +Stdlib only. Refuses anything that isn't a PNG, and sanitises the name to a bare basename so a +page can't post its way out of the shots directory. +""" +import argparse, os, re, sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +OUTDIR = os.path.join(ROOT, "docs", "shots", "laneD") + + +class Sink(BaseHTTPRequestHandler): + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def _read_body(self): + """Content-Length when present, else chunked. fetch(body: Blob) normally sends a + Content-Length, but some embedded browsers stream it chunked instead — and + read(Content-Length or 0) silently returns b"" for those, which looks exactly like + "not a PNG". Handle both rather than rejecting a perfectly good frame.""" + cl = self.headers.get("Content-Length") + if cl is not None: + return self.rfile.read(int(cl)) + if (self.headers.get("Transfer-Encoding") or "").lower() != "chunked": + return b"" + buf = bytearray() + while True: + n = int(self.rfile.readline().split(b";")[0] or b"0", 16) + if n == 0: + self.rfile.readline() # trailing CRLF + return bytes(buf) + buf += self.rfile.read(n) + self.rfile.readline() # CRLF after each chunk + + def do_POST(self): + m = re.match(r"^/shot/([\w.-]+)$", self.path) + if not m: + self.send_response(404); self._cors(); self.end_headers(); return + name = os.path.basename(m.group(1)) + if not name.endswith(".png"): + name += ".png" + body = self._read_body() + if not body.startswith(PNG_MAGIC): # only ever write real PNGs + print(f" reject {name}: {len(body)}B, head={body[:8]!r}, " + f"cl={self.headers.get('Content-Length')!r}, " + f"te={self.headers.get('Transfer-Encoding')!r}", flush=True) + self.send_response(415); self._cors(); self.end_headers() + self.wfile.write(b"not a png"); return + path = os.path.join(OUTDIR, name) + with open(path, "wb") as f: + f.write(body) + print(f" shot {name} {len(body)//1024}KB -> {os.path.relpath(path, ROOT)}", flush=True) + self.send_response(200); self._cors(); self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *a): # the prints above are the only log we want + pass + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8141) + ap.add_argument("--dir", default=OUTDIR) + a = ap.parse_args() + OUTDIR = os.path.abspath(a.dir) + os.makedirs(OUTDIR, exist_ok=True) + print(f"shot sink: POST http://localhost:{a.port}/shot/.png -> {OUTDIR}", flush=True) + ThreadingHTTPServer(("127.0.0.1", a.port), Sink).serve_forever() diff --git a/web/assets/audio/bed-esophagus.m4a b/web/assets/audio/bed-esophagus.m4a new file mode 100644 index 0000000..9b7bcb8 Binary files /dev/null and b/web/assets/audio/bed-esophagus.m4a differ diff --git a/web/assets/audio/bed-esophagus.ogg b/web/assets/audio/bed-esophagus.ogg new file mode 100644 index 0000000..4b4bcd3 Binary files /dev/null and b/web/assets/audio/bed-esophagus.ogg differ diff --git a/web/assets/audio/sfx-boost.m4a b/web/assets/audio/sfx-boost.m4a new file mode 100644 index 0000000..ed35224 Binary files /dev/null and b/web/assets/audio/sfx-boost.m4a differ diff --git a/web/assets/audio/sfx-boost.ogg b/web/assets/audio/sfx-boost.ogg new file mode 100644 index 0000000..7af4774 Binary files /dev/null and b/web/assets/audio/sfx-boost.ogg differ diff --git a/web/assets/audio/sfx-hit-squelch.m4a b/web/assets/audio/sfx-hit-squelch.m4a new file mode 100644 index 0000000..414b45a Binary files /dev/null and b/web/assets/audio/sfx-hit-squelch.m4a differ diff --git a/web/assets/audio/sfx-hit-squelch.ogg b/web/assets/audio/sfx-hit-squelch.ogg new file mode 100644 index 0000000..44d0638 Binary files /dev/null and b/web/assets/audio/sfx-hit-squelch.ogg differ diff --git a/web/assets/audio/sfx-pellet.m4a b/web/assets/audio/sfx-pellet.m4a new file mode 100644 index 0000000..c7a55f0 Binary files /dev/null and b/web/assets/audio/sfx-pellet.m4a differ diff --git a/web/assets/audio/sfx-pellet.ogg b/web/assets/audio/sfx-pellet.ogg new file mode 100644 index 0000000..f5dbc28 Binary files /dev/null and b/web/assets/audio/sfx-pellet.ogg differ diff --git a/web/assets/audio/sfx-pickup.m4a b/web/assets/audio/sfx-pickup.m4a new file mode 100644 index 0000000..eda1a5f Binary files /dev/null and b/web/assets/audio/sfx-pickup.m4a differ diff --git a/web/assets/audio/sfx-pickup.ogg b/web/assets/audio/sfx-pickup.ogg new file mode 100644 index 0000000..24524f3 Binary files /dev/null and b/web/assets/audio/sfx-pickup.ogg differ diff --git a/web/assets/gen/matcap_tissue_wet.webp b/web/assets/gen/matcap_tissue_wet.webp new file mode 100644 index 0000000..b048875 Binary files /dev/null and b/web/assets/gen/matcap_tissue_wet.webp differ diff --git a/web/assets/gen/wall_esophagus_a.webp b/web/assets/gen/wall_esophagus_a.webp new file mode 100644 index 0000000..cbe1e98 Binary files /dev/null and b/web/assets/gen/wall_esophagus_a.webp differ diff --git a/web/assets/gen/wall_esophagus_a_n.webp b/web/assets/gen/wall_esophagus_a_n.webp new file mode 100644 index 0000000..770db35 Binary files /dev/null and b/web/assets/gen/wall_esophagus_a_n.webp differ diff --git a/web/assets/gen/wall_esophagus_b.webp b/web/assets/gen/wall_esophagus_b.webp new file mode 100644 index 0000000..137ffc8 Binary files /dev/null and b/web/assets/gen/wall_esophagus_b.webp differ diff --git a/web/assets/gen/wall_esophagus_b_n.webp b/web/assets/gen/wall_esophagus_b_n.webp new file mode 100644 index 0000000..ef2a777 Binary files /dev/null and b/web/assets/gen/wall_esophagus_b_n.webp differ diff --git a/web/assets/gen/wall_smallint_a.webp b/web/assets/gen/wall_smallint_a.webp new file mode 100644 index 0000000..1c83b0c Binary files /dev/null and b/web/assets/gen/wall_smallint_a.webp differ diff --git a/web/assets/gen/wall_smallint_a_n.webp b/web/assets/gen/wall_smallint_a_n.webp new file mode 100644 index 0000000..b3b4cd4 Binary files /dev/null and b/web/assets/gen/wall_smallint_a_n.webp differ diff --git a/web/assets/gen/wall_smallint_b.webp b/web/assets/gen/wall_smallint_b.webp new file mode 100644 index 0000000..bab6aa4 Binary files /dev/null and b/web/assets/gen/wall_smallint_b.webp differ diff --git a/web/assets/gen/wall_smallint_b_n.webp b/web/assets/gen/wall_smallint_b_n.webp new file mode 100644 index 0000000..d515268 Binary files /dev/null and b/web/assets/gen/wall_smallint_b_n.webp differ diff --git a/web/assets/gen/wall_stomach_a.webp b/web/assets/gen/wall_stomach_a.webp new file mode 100644 index 0000000..09757ce Binary files /dev/null and b/web/assets/gen/wall_stomach_a.webp differ diff --git a/web/assets/gen/wall_stomach_a_n.webp b/web/assets/gen/wall_stomach_a_n.webp new file mode 100644 index 0000000..33b5678 Binary files /dev/null and b/web/assets/gen/wall_stomach_a_n.webp differ diff --git a/web/assets/gen/wall_stomach_b.webp b/web/assets/gen/wall_stomach_b.webp new file mode 100644 index 0000000..0ed3fc6 Binary files /dev/null and b/web/assets/gen/wall_stomach_b.webp differ diff --git a/web/assets/gen/wall_stomach_b_n.webp b/web/assets/gen/wall_stomach_b_n.webp new file mode 100644 index 0000000..d1b5f05 Binary files /dev/null and b/web/assets/gen/wall_stomach_b_n.webp differ diff --git a/web/assets/manifest.json b/web/assets/manifest.json new file mode 100644 index 0000000..2e56241 --- /dev/null +++ b/web/assets/manifest.json @@ -0,0 +1,99 @@ +{ + "audio": { + "beds": { + "esophagus": { + "gain": 0.5, + "loop": true, + "m4a": "audio/bed-esophagus.m4a", + "ogg": "audio/bed-esophagus.ogg", + "seconds": 24.0 + } + }, + "sfx": { + "boost": { + "gain": 0.6, + "loop": false, + "m4a": "audio/sfx-boost.m4a", + "ogg": "audio/sfx-boost.ogg", + "seconds": 0.85 + }, + "hit_squelch": { + "gain": 0.7, + "loop": false, + "m4a": "audio/sfx-hit-squelch.m4a", + "ogg": "audio/sfx-hit-squelch.ogg", + "seconds": 0.34 + }, + "pellet": { + "gain": 0.5, + "loop": false, + "m4a": "audio/sfx-pellet.m4a", + "ogg": "audio/sfx-pellet.ogg", + "seconds": 0.2 + }, + "pickup": { + "gain": 0.5, + "loop": false, + "m4a": "audio/sfx-pickup.m4a", + "ogg": "audio/sfx-pickup.ogg", + "seconds": 0.5 + } + } + }, + "matcaps": { + "tissue_wet": { + "url": "gen/matcap_tissue_wet.webp" + } + }, + "models": {}, + "textures": { + "wall_esophagus_a": { + "normal": "gen/wall_esophagus_a_n.webp", + "tile": [ + 4, + 16 + ], + "url": "gen/wall_esophagus_a.webp" + }, + "wall_esophagus_b": { + "normal": "gen/wall_esophagus_b_n.webp", + "tile": [ + 3, + 12 + ], + "url": "gen/wall_esophagus_b.webp" + }, + "wall_smallint_a": { + "normal": "gen/wall_smallint_a_n.webp", + "tile": [ + 5, + 14 + ], + "url": "gen/wall_smallint_a.webp" + }, + "wall_smallint_b": { + "normal": "gen/wall_smallint_b_n.webp", + "tile": [ + 6, + 10 + ], + "url": "gen/wall_smallint_b.webp" + }, + "wall_stomach_a": { + "normal": "gen/wall_stomach_a_n.webp", + "tile": [ + 4, + 18 + ], + "url": "gen/wall_stomach_a.webp" + }, + "wall_stomach_b": { + "normal": "gen/wall_stomach_b_n.webp", + "tile": [ + 3, + 22 + ], + "url": "gen/wall_stomach_b.webp" + } + } +} \ No newline at end of file diff --git a/web/dev/laneD_texview.html b/web/dev/laneD_texview.html new file mode 100644 index 0000000..d871038 --- /dev/null +++ b/web/dev/laneD_texview.html @@ -0,0 +1,231 @@ + + + + +GUTS — Lane D texture viewer (dev) + + + + + +
+ + + diff --git a/web/js/core/assets.js b/web/js/core/assets.js index aaf41c7..90781bc 100644 --- a/web/js/core/assets.js +++ b/web/js/core/assets.js @@ -36,6 +36,18 @@ export async function createAssets({ flags = {}, renderer = null } = {}) { const maxAniso = renderer?.capabilities?.getMaxAnisotropy?.() ?? 1; let manifest = EMPTY; + // Miss ledger. The optional-asset law has one nasty edge, found by Lane A in round 1: ask + // for a name that doesn't exist (a typo, or a slug that drifted from a biome id) and you + // get the procedural fallback FOREVER, with no error — the game looks fine and the texture + // is simply never seen. So every miss is recorded, and each distinct one says so once. + // `assets.misses()` lists them; noisy-by-design under ?localassets=0, so stay quiet there. + const missed = new Set(); + const noteMiss = (key) => { + if (missed.has(key)) return; + missed.add(key); + if (flags.localassets !== false) console.info(`[assets] miss: ${key} — using fallback`); + }; + // ?localassets=0 => pretend the manifest is empty. This is the fallback-path test switch: // if the game looks broken with it on, someone has violated the optional-asset law. if (flags.localassets === false) { @@ -84,7 +96,14 @@ export async function createAssets({ flags = {}, renderer = null } = {}) { const cat = manifest?.[category]; if (!cat) return null; if (category === 'audio') return null; // audio is nested; use audioUrl() - return cat[name] ?? null; + const e = cat[name] ?? null; + if (!e) noteMiss(`${category}/${name}`); + return e; + }, + + /** Names asked for that the manifest didn't have. Check this if art "isn't showing up". */ + misses() { + return [...missed]; }, /**