# 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=<port>` 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.
