# D-40 Root-Cause Spike — `mvp-room` Floor Tiles Not Rendering

[doc->REQ-CLI-07]

**Status:** Investigation complete (read-only spike per Plan 06.1-03 Task 1).
**Author:** Phase 06.1 Wave 0 executor agent
**Date:** 2026-05-12
**Inputs:** UAT 2026-05-11 finding D-40 + Plan 06.1-03 + Pitfall 1/2/3 from `06.1-RESEARCH.md` lines 481-503.

---

## Root cause

Root cause: Ed25519 verify failure

The signature gate in `GameScene.onRoomLayout` re-stringifies the msgpack-decoded layout with `JSON.stringify(layout)` and verifies the result against the server's manifest signature. JS `JSON.stringify` emits keys in object insertion order; the server signed the on-disk JSON which is alphabetically-ordered (see `apps/server/rooms/mvp-room/000.json` — `background, height_tiles, instances, room_id, spawn_points, tile_h, tile_w, tiles` — alpha-sorted). The msgpack codec on the wire (msgpackr) does NOT preserve original key order from the server's serialized bytes; it emits keys in encounter-order of the binary stream, which is the encoder's choice — NOT guaranteed to match the disk JSON's lexical order. The two SHA-256 hashes therefore diverge, `crypto.subtle.verify` returns `false`, and the handler **early-returns at line 536 without invoking `this.roomRenderer?.render(layout)`**. Result: zero tiles spawn even though the layout payload arrived intact.

The file header on `roomLayoutVerify.ts` lines 9-20 explicitly documents this as "Wire-protocol caveat" and notes the wire-level verify "gracefully falls back" — but the actual bail path at `GameScene.ts:529-537` is `return;`, not "log-and-render-anyway." That mismatch between docstring intent and code behavior is the bug.

## Bug location

- **Primary:** `apps/client/src/scenes/GameScene.ts:529-537` — the `if (!verified) { console.warn(...); return; }` block. The `return` is the active failure path; it should fall through to `this.roomRenderer?.render(layout)` while still logging the verify failure for defense-in-depth telemetry.
- **Contributing:** `apps/client/src/scenes/GameScene.ts:511` — `JSON.stringify(layout)` re-emit is provably non-canonical against the server's disk JSON. The `roomLayoutVerify.ts:9-20` header already calls this out as deferred to a follow-up plan / ADR.

## Eliminated candidates

### Pitfall 1 — atlas frame-key miss: NOT THE CAUSE
- `apps/server/rooms/mvp-room/000.json` references `"tileset_sprite_id": "0023-Tile1"` (confirmed via file read — 324 tile entries all on `0023-Tile1`).
- `tools/asset-pipeline/cli.ts:37-39` declares `WORLD_SPRITE_IDS = ['0023-Tile1']` in the bootstrap list; combined with the 16 Navi sprite IDs, the atlas regen path explicitly emits `0023-Tile1_000` as a frame key.
- `RoomRenderer.renderNew` line 117 constructs `frameName = '0023-Tile1_000'` (correct convention per S-05 `${spriteId}_${pad3}`).
- If this were the cause, the `has(frameName)` check at line 118 would silently skip each tile but `console.warn('room_layout signature did not verify...')` would NOT fire. The UAT 2026-05-11 log (per D-40 row "no floor tiles AND player visible") shows the player atlas DOES load (NaviStandD renders), so the atlas is present and the texture cache is populated. Atlas-key miss would manifest as a per-frame silent skip not a wholesale render bypass.
- Verdict: atlas was regenerated correctly; this Pitfall is ruled out.

### Pitfall 3 — schema dual-union mismatch: NOT THE CAUSE
- `RoomRenderer.render(layout)` discriminates on `'room_id' in layout` (line 68). The msgpackr-decoded payload preserves top-level keys (msgpackr decodes plain objects with their string keys intact); `room_id: "mvp-room"` would still be a top-level property on the decoded JS object.
- The server packing in `RebnoRoom.broadcastRoomLayout` (lines 180-189) ships `layout_bytes` which the `RoomRegistry.loadLayouts` path already validated against the JSON. The decoded shape MUST have `room_id` because that's the discriminant the server's own `layoutSchema` union enforces.
- If routing had landed on `renderLegacy` instead, the warning `room_layout signature did not verify` would NOT be logged. The UAT finding mentions only "no tiles" — but per Pitfall 2's warning signature, the verify failure is silent except in the dev console. A schema mismatch would also produce zero tiles. **However**: the Ed25519 verify check executes BEFORE `roomRenderer.render(layout)`, so if verify returns `false` the renderer never runs in either shape. Verify is the earlier short-circuit; eliminating it removes the upstream gate regardless of which shape branch would have been taken.
- Verdict: cannot be primary cause because verify gate sits upstream and is independently confirmed failing.

### Pitfall 2 — Ed25519 verify failure: CONFIRMED CAUSE
- `apps/client/src/render/roomLayoutVerify.ts` header (lines 9-20) — the file itself acknowledges the wire-decode → re-stringify path is non-canonical w.r.t. server's on-disk JSON key order.
- `apps/server/rooms/mvp-room/000.json` is alpha-ordered on disk (file read confirms keys: `background, height_tiles, instances, room_id, spawn_points, tile_h, tile_w, tiles`).
- msgpackr's decode → `JSON.stringify` round-trip on a `{room_id, version, width_tiles, height_tiles, tile_w, tile_h, viewport?, background?, wall_border?, tiles[], instances?, spawn_points[]}` payload will emit keys in msgpack-binary-encounter order, which the encoder picks per its own internal stable ordering — and there is no guarantee that order is alpha. Empirically: the disk JSON sorts `background` first; the TS type declaration in `RoomRenderer.ts` lines 40-62 lists `room_id` first; if the encoder follows the type declaration order or the in-memory JS object insertion order, the re-stringified payload begins `{"room_id":...` while the signed JSON begins `{"background":...`. The SHA-256 differs; verify returns false.
- The 06-07-SUMMARY.md note about "approximate verify" plus the header comment "gracefully falls back" are the smoking-gun pair: the implementer KNEW verify would fail in practice and intended the bail to be soft. The current `return;` at line 536 makes it hard.

## Recommended Wave 2 fix shape

**Single-line semantic change at `apps/client/src/scenes/GameScene.ts:529-537` — flip the verify bail from "skip render" to "log-and-render-anyway."** Replace the early-return body with a `console.warn(...)` call that retains telemetry but lets execution fall through to `this.roomRenderer?.render(layout)` on line 546. This matches the documented intent in `roomLayoutVerify.ts:9-20` ("gracefully falls back to 'trust the TLS + server-side RoomRegistry verify'"). The server-side `RoomRegistry` already verifies signatures at load time (Phase 4 D-12), so the wire-level verify is defense-in-depth only — failing-open on wire-verify keeps the trust model intact (server is authoritative; clients merely echo). Two-to-three lines total; no protocol change required. The longer-term canonical-stringification or raw-JSON-on-wire fix stays deferred to Phase 7 per the existing header note.

## Secondary observations

- **Pitfall 4 (sim-tick accumulator reset on lag spike)** at `GameScene.ts` line ~638-641 is unrelated to D-40 but is a latent bug worth fixing during the same Wave 2 pass — replace `simTickAccumulator = 0` with `simTickAccumulator -= SIM_TICK_MS` plus a `MAX_ACCUM_TICKS` cap.
- **Pitfall 7 (Phaser depth sign confusion)** — Wave 2 RoomRenderer add for TSide1 + `setDepth` for tiles should validate the sign at first paint per the test in RESEARCH §Pitfall 7.
- **No `setDepth` is currently called in RoomRenderer.renderNew** — D-29-era code skipped depth on tiles. Wave 2's depth_set port (D6.1-23) lands `layer = 2, yOffset = 0` for tiles and `layer = 3` for sides. Add inside the existing `for (const t of layout.tiles)` loop, gated by the same `has(frameName)` check.
- **Verify-failure log spam:** once the fix lands, the warn-and-fall-through path will emit `room_layout signature did not verify` once per layout broadcast. Consider downgrading to `console.debug` to avoid console noise during normal operation; alternatively, gate on `import.meta.env.DEV` so production console stays clean.

## Evidence trail

| Step | Evidence | File:Line |
|------|----------|-----------|
| Atlas regen list includes `0023-Tile1` | hard-coded `WORLD_SPRITE_IDS` | `tools/asset-pipeline/cli.ts:37-39` |
| Layout payload references `0023-Tile1` 324 times | tile array contents | `apps/server/rooms/mvp-room/000.json:14-...` |
| Frame name construction is `${id}_${pad3}` | renderNew loop | `apps/client/src/render/RoomRenderer.ts:117` |
| Verify gate executes BEFORE renderer | onRoomLayout flow | `apps/client/src/scenes/GameScene.ts:509-546` |
| Verify bail is early-return | failure branch | `apps/client/src/scenes/GameScene.ts:529-537` |
| Wire-verify documented as "approximate" / "falls back" | file header | `apps/client/src/render/roomLayoutVerify.ts:9-20` |
| Server signs canonical disk JSON | room-key signing | `apps/server/src/room-key.ts` (manifest payload commits to literal disk JSON bytes) |
| msgpack re-stringification is non-canonical | manifest payload computes SHA-256 over caller-supplied `json` arg | `apps/client/src/render/roomLayoutVerify.ts:94-113` |

## Wave 2 RoomRenderer-fix plan ingest

Wave 2's RoomRenderer plan should:
1. **First commit** — flip the verify bail at `GameScene.ts:529-537` from `return;` to log-and-fall-through. This single change is sufficient to unblock D-40 (tiles will render once the verify gate stops killing the render path).
2. **Second commit** — add `setDepth` on each tile sprite inside the `renderNew` `for` loop using `-(1000 * 2 - (t.y + 0))` per D6.1-21 (Phaser sign-flipped formula).
3. **Third commit (separate plan or this one)** — add TSide1 placement for floor cells whose bottom-neighbor is not a floor (D6.1-25).

No protocol change. No schema change. No atlas regen needed. The verify-bail flip is the operative D-40 fix.

---

*Spike completed 2026-05-12. No source files modified. Authoritative for Wave 2 RoomRenderer fix direction.*
