---
phase: 06.1-gap-closure-d-39-d-46-uat-2026-05-11
plan: 05
subsystem: client-render
tags: [render, d-40-fix, background, walkable-grid, tside1, depth-set]
requires: ["06.1-01", "06.1-02", "06.1-03"]
provides:
  - apps/client/src/render/RoomCollision.ts — pure helpers (deriveWalkableGrid + bottomEdgeTiles + isFeetBboxWalkable)
  - apps/client/src/render/BackgroundRenderer.ts — bkdraw port (dxspeed/dyspeed/bgspeed wrap math + render-frame position)
  - RoomRenderer.walkableGrid public accessor (consumed by Plan 06.1-06 GameScene plumbing)
  - D-40 fix — Ed25519 verify-bail flipped from early-return to log-and-fall-through
affects:
  - apps/client/src/render/RoomRenderer.ts
  - apps/client/src/scenes/GameScene.ts
  - apps/client/src/__test__/background-renderer.test.ts
  - apps/client/src/__test__/room-collision-bottom-edge.test.ts
tech-stack:
  added: []
  patterns:
    - "S-02 extract→cite→name: every constant in BackgroundRenderer carries a `SOURCE:` GML/meta.json citation"
    - "S-06 optional-chain Phaser API (`setDepth?.`, `setFrame?.`): jsdom Rectangle fallback safety"
    - "S-08 __rebno test hook: tileCount + tsideCount + bgframe/bgDxoff under DEV/test only"
    - "Inline depth-layer constants per renderer (D6.1-24 — no shared depth-registry yet)"
    - "Sign-flipped depth_set formula: depth = -(1000*layer - (y + yOffset)) (GameMaker→Phaser axis flip, Pitfall 7)"
key-files:
  created:
    - apps/client/src/render/RoomCollision.ts
    - apps/client/src/render/BackgroundRenderer.ts
    - apps/client/src/__test__/background-renderer.test.ts
    - apps/client/src/__test__/room-collision-bottom-edge.test.ts
  modified:
    - apps/client/src/render/RoomRenderer.ts
    - apps/client/src/scenes/GameScene.ts
decisions:
  - D-40 root cause confirmed = Ed25519 wire-verify failure (msgpack→JSON.stringify reorders keys vs server's alpha-ordered disk JSON). Fix is the single-line bail flip per 06.1-D40-SPIKE.md §"Recommended Wave 2 fix shape". No protocol change, no atlas regen, no schema change.
  - RoomCollision.ts lives under apps/client/src/render/ (not packages/game-logic) for MVP — preserves wire format. Helpers are pure-data-in / pure-data-out so future relocation is trivial if a server-side walkable-grid derivation is wanted.
  - Tile depth uses TILE_LAYER=2 (per `0020-borderedtile/Create.gml:2 depth_set(2,0)`) inline, not via a shared registry — matches D6.1-24 anti-consolidation rule for MVP.
  - TSide1 depth uses TSIDE_LAYER=3 (per `0021-tside1/Create.gml:2 depth_set(3,0)`); bottom-edge spawn ONLY (top/left/right anti-port, legacy tileborder.gml:1-7).
  - Legacy `wall_border` Rectangle code path retained but flagged dormant (`// LEGACY mvp-lobby fallback — superseded by walkable-grid in 06.1-05`). No deletion; mvp-lobby still routes through it until rooms are re-emitted.
  - renderLegacy `.setDepth(-100)` chained call replaced with `.setOrigin(0,0); rect.setDepth?.(-100);` (S-06 optional-chain). Required because the D-40 fix now allows verify-failure fall-through into renderLegacy, exposing the prior latent gap in the game-scene test mock's `add.rectangle`.
metrics:
  duration: "~25 minutes"
  completed_date: "2026-05-12"
  tasks_completed: 2
  files_created: 4
  files_modified: 2
  tests_added: 12
  tests_total_passing: 12
requirements: [REQ-CLI-06, REQ-CLI-07]
---

# Phase 06.1 Plan 05: D-40 Fix + BackgroundRenderer + RoomCollision Summary

Single-line D-40 root-cause fix (Ed25519 verify-bail flip at `GameScene.ts:529-537`) unblocks every floor-tile render. Wave 2 ships BackgroundRenderer (port of 0051-bkdraw), RoomCollision (pure walkable-grid helpers), and RoomRenderer additions (per-tile depth, TSide1 bottom-edge placement, walkableGrid accessor for Plan 06.1-06 to plumb).

## Objective

Close D-40 (floor tiles not rendering — root cause from `06.1-D40-SPIKE.md`), D6.1-25 (TSide1 bottom-edge sides), D6.1-21/23 (per-tile depth_set), D6.1-17..20 (bkdraw background port), and D6.1-29 (walkable-region mask derivation client-side). Outputs unblock Plan 06.1-06 (GameScene wire-up) and Plan 06.1-07 (Wave 4 e2e tests asserting `__rebno.tileCount > 0` and `__rebno.tsideCount > 0`).

## Tasks Completed

| # | Task | Commit | Files |
|---|------|--------|-------|
| 1 | RED Wave 0 — pin BackgroundRenderer wrap math + bottomEdgeTiles perimeter correctness | `9076f52` | 2 new test files |
| 2 | GREEN — D-40 fix + RoomCollision + BackgroundRenderer + RoomRenderer additions | `2bedc04` | 4 (2 new src, 2 modified) |

## D-40 Root Cause + Fix

**Root cause** (per `06.1-D40-SPIKE.md`): The `GameScene.onRoomLayout` handler verifies an Ed25519 manifest signature against `JSON.stringify(msgpackUnpack(layout_bytes))`. JS `JSON.stringify` emits keys in object-insertion order; msgpackr's decode does NOT preserve original key order; the server signs the on-disk **alpha-ordered** JSON. The two SHA-256 hashes therefore diverge by design — verify=false is the expected path. The previous `return;` at line 536 treated this as fatal and silently skipped `roomRenderer.render(layout)`. Net effect: **zero tiles rendered**, even though the payload arrived intact.

**Fix shape** (single-line semantic change at `apps/client/src/scenes/GameScene.ts:529-537`): flipped the early-return body from `return;` to a `console.warn(...)` that falls through to `this.roomRenderer?.render(layout)`. Defense-in-depth telemetry is preserved; the warn now reads `room_layout signature did not verify — rendering anyway (defense-in-depth; see 06.1-D40-SPIKE.md)`. The trust path stays intact:
- **Primary:** server-side `RoomRegistry.loadLayouts` Ed25519 verify-at-load (Phase 4 D-12).
- **Secondary:** TLS in transit.
- **Tertiary (this layer):** client-side warn for telemetry only.

This matches the documented intent in `roomLayoutVerify.ts:9-20` ("gracefully falls back"). Long-term canonical-stringification fix is deferred to Phase 7 per the existing header note.

**Cascade auto-fix discovered during GREEN run:** With the D-40 bail flipped to fall-through, `roomRenderer.render(layout)` now executes against test-time `mvp-lobby` legacy layouts (no `room_id` discriminant → routes to `renderLegacy`). The `renderLegacy` background-fallback path was chaining `.setOrigin(0,0).setDepth(-100)` on `Phaser.GameObjects.Rectangle`. The game-scene test mock's `add.rectangle()` returns a sprite stub without `setDepth`. Pre-D-40-fix this was masked by the verify-failure short-circuit; post-fix it's an unhandled rejection. Applied S-06 optional-chain (`rect.setDepth?.(-100)`) to both `img` and `rect` paths in `renderLegacy`. Tracked as Rule 1 auto-fix below.

## Implementation Notes

### `RoomCollision.ts` — pure walkable-grid helpers (D6.1-29)

Three exports, all pure-data-in / pure-data-out:

```ts
export interface WalkableGrid {
  width_tiles: number; height_tiles: number;
  tile_w: 44; tile_h: 40;            // CLAUDE.md load-bearing — NEVER 32×32
  cells: Uint8Array;                  // row-major: cells[row*W + col] = 1 iff walkable
}
export function deriveWalkableGrid(layout): WalkableGrid;
export function bottomEdgeTiles(grid): Array<{ x: number; y: number }>;
export function isFeetBboxWalkable(grid, l, t, r, b): boolean;
```

`deriveWalkableGrid` scans `layout.tiles[]` and marks each tile's `(col=floor(x/44), row=floor(y/40))` cell walkable. The grid is the UNION of all floor-tile rectangles.

`bottomEdgeTiles` iterates the grid; for each walkable cell whose bottom-neighbor is NOT walkable (or out-of-bounds), emits a TSide1 spawn at `(col*44, row*40 + 40)`. Port of `extracted/client-5-8/scripts/0085-tileborder.gml:1-7` — anti-port enforcement: top/left/right are commented out in the legacy GML and MUST NOT be reactivated (CONTEXT D6.1-25).

`isFeetBboxWalkable` is the 4-corner probe consumed by `step()`'s per-axis sub-pixel collision loop (Plan 06.1-02 already wired it in `packages/game-logic/src/collision.ts`; this is a browser-side mirror so client-side prediction and `step()` agree).

### `BackgroundRenderer.ts` — bkdraw port (D6.1-17..20)

State fields with SOURCE citations:

| Field | Value | SOURCE |
|-------|-------|--------|
| `dxspeed` | 0.25 | `extracted/client-5-8/rooms/0058-BNCentral/instances.json:3` (creation code) |
| `dyspeed` | -1 | same |
| `bgspeed` | 0.25 | `extracted/client-5-8/objects/0051-bkdraw/events/Create.gml:2` (image_speed) |
| `spriteW`, `spriteH` | 32, 32 | `extracted/client-5-8/sprites/0064-BKA1/meta.json` |
| `bgframes` | 55 | same |

Lifecycle:
- `constructor(scene, atlasKey='atlas-mvp')` — minimal, only creates the sprite group.
- `build()` — one-time grid of `(viewW/32+2) × (viewH/32+2)` sprites at `scrollFactor=0` (viewport-anchored, RESEARCH Q4) and depth `-10000` (far behind world). Atlas-missing guard with warn-once on missing frame key (D-40 instrumentation).
- `onSimulationTick()` — fires from GameScene's sim-tick block (D-31 lock). Advances `dxoff/dyoff/bgframe`; wraps `dxoff` at `±32`, `dyoff` at `±32`, `bgframe` at `55`. Verbatim port of `Step.gml`.
- `tickRender(camera)` — fires every render frame (60 Hz, NOT gated by sim-tick). Positions each sprite at `(col*32 + dxoff - 32, row*32 + dyoff - 32)` and sets the current bgframe key via `setFrame?.()` (S-06 optional-chain for Rectangle fallback).
- `getState()` — pure introspection accessor for Wave 0 unit tests. No side effects.
- `dispose()` — `group.clear(true, true)` plus `built = false` so a subsequent `build()` rebuilds cleanly (S-07 hot-swap).

### `RoomRenderer.ts` — additions (D6.1-21/23/25/29, D-40 instrumentation)

- New `import { deriveWalkableGrid, bottomEdgeTiles, type WalkableGrid } from './RoomCollision.js';`.
- New `public walkableGrid: WalkableGrid | undefined;` — Plan 06.1-06 GameScene plumbs this into `state.room_layout.walkable_grid` before invoking client-side `step()`.
- In `render(layout)`: when new SRV-13 shape detected, call `deriveWalkableGrid(layout)` and stash on `this.walkableGrid` BEFORE rendering. Reset to `undefined` on legacy/empty paths.
- In `renderNew(layout)`:
  - Tile loop: D-40 instrumentation — `warnedFrames` Set ensures one warn per missing key (`'RoomRenderer: missing atlas frame'`). Per-tile depth via inline constants `TILE_LAYER=2, TILE_Y_OFFSET=0` and sign-flip formula `depth = -(1000*TILE_LAYER - (t.y + TILE_Y_OFFSET))`. SOURCE: `0020-borderedtile/Create.gml:2 depth_set(2,0)`.
  - New TSide1 placement block: iterates `bottomEdgeTiles(this.walkableGrid)` and places `0024-TSide1_000` sprites at returned positions, depth via `TSIDE_LAYER=3` (SOURCE: `0021-tside1/Create.gml:2 depth_set(3,0)`). Warn-once on missing frame.
  - S-08 `__rebno.tileCount` + `__rebno.tsideCount` test hooks under `import.meta.env.DEV || MODE === 'test'`.
- In `renderLegacy(layout)`: applied S-06 optional-chain to both image and rectangle paths (`img.setDepth?.(-100)`, `rect.setDepth?.(-100)`). Necessary because D-40 fix now allows fall-through into this path with verify=false. Comment `// LEGACY mvp-lobby fallback — superseded by walkable-grid in 06.1-05` flags the block as dormant.

### `GameScene.ts` — D-40 fix only

Lines 529-537 changed:

```ts
// BEFORE
if (!verified) {
  console.warn('room_layout signature did not verify — rendering skipped for', evt.room_id, evt.layout_rev);
  return;
}

// AFTER
if (!verified) {
  // [impl->REQ-CLI-06] Plan 06.1-05 D-40 fix per 06.1-D40-SPIKE.md:
  // ... (canonical-stringification deferred; this layer is defense-in-depth) ...
  console.warn(
    'room_layout signature did not verify — rendering anyway (defense-in-depth; see 06.1-D40-SPIKE.md)',
    evt.room_id, evt.layout_rev,
  );
}
```

No other changes to GameScene — sim-tick wire-up (BackgroundRenderer + walkableGrid plumb-in) is Plan 06.1-06's scope per the plan tree.

## Tests (Wave 0)

| Test | Cases | Description |
|------|-------|-------------|
| `background-renderer.test.ts` | 7 | Wrap math at dxspeed/dyspeed/bgspeed; bounds invariants; initial state |
| `room-collision-bottom-edge.test.ts` | 5 | 3×3 → 3 entries; 1×1 → 1 entry; 0×0 → []; anti-port assertion; 44×40 pitch (NOT 32×32) |

Both files include the `// [unit->REQ-CLI-06]` / `// [unit->REQ-CLI-07]` traceable-reqs tags. RED captured at commit `9076f52` (failed because `BackgroundRenderer.ts`/`RoomCollision.ts` did not yet exist). GREEN at `2bedc04`: 12/12 pass in 36ms.

## Verification

| Gate | Command | Result |
|---|---|---|
| Wave 0 RED captured | `pnpm --filter @rebno/client test src/__test__/background-renderer.test.ts src/__test__/room-collision-bottom-edge.test.ts` (at `9076f52`) | 2 files failed (imports unresolved) — RED |
| Wave 0 GREEN | same command (at `2bedc04`) | **12/12 pass** |
| Client TypeScript | `pnpm --filter @rebno/client exec tsc --noEmit` | **clean (0 errors)** |
| D-40 fix smoke test | `pnpm --filter @rebno/client test src/__test__/game-scene.test.ts` | **8/8 pass** (verify-failure path no longer short-circuits render) |
| Trace status | `pnpm exec traceable-reqs trace REQ-CLI-06` / `REQ-CLI-07` | **[OK]** (+doc +impl +unit; int already covered by Plan 06.1-03 e2e skeletons) |
| Full client test sweep | `pnpm --filter @rebno/client test` | 160/166 pass; 2 pre-existing prediction.test.ts failures (unrelated — see Deferred); 4 todo |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 — Bug] `renderLegacy` chained `setDepth` failed on jsdom test mocks**
- **Found during:** Task 2 full-suite test run after D-40 fix.
- **Issue:** `apps/client/src/render/RoomRenderer.ts:264, 272` (renderLegacy background-fallback paths) chained `.setOrigin(0,0).setDepth(-100)` on `Phaser.GameObjects.Image` and `Rectangle`. The game-scene test mock's `add.rectangle()` returns a sprite stub built by `makeSprite()` which lacks `setDepth`. Pre-D-40-fix this never executed because verify=false short-circuited `roomRenderer.render(layout)`. Post-fix the verify-failure now falls through into `renderLegacy` (test layout has no `room_id` discriminant), exposing the latent gap as an unhandled rejection that fails the game-scene test file.
- **Fix:** Applied S-06 optional-chain pattern (`img.setDepth?.(-100)`, `rect.setDepth?.(-100)`). Matches `PlayerRenderer.ts:138, 181` convention.
- **Files affected:** `apps/client/src/render/RoomRenderer.ts` (renderLegacy)
- **Commit:** `2bedc04` (same commit as the D-40 fix that exposed it)

### Out-of-Scope Findings (Deferred)

- **`apps/client/src/__test__/prediction.test.ts` 2 pre-existing failures.** `PredictionEngine > predictTick after ack-and-empty-queue applies pure friction decay` and `PredictionEngine > applyServerSnapshot replays unacked inputs forward from snapshot` both fail with `expected 0 to be greater than 0` on `vx`. Confirmed pre-existing by stashing all Plan 06.1-05 changes and re-running — failures persist. Likely a downstream consequence of Plan 06-13 (`feat(06-13): rewrite step() with BNO-faithful constants`) which removed friction/acceleration. Tracked for a separate plan or a `prediction.test.ts` update; not in scope for Plan 06.1-05.
- **`pnpm trace:check` reports pre-existing `undeclared_id` findings** in Phase 04 placeholder plans (REQ-SRV-XX). Not touched by this plan. REQ-CLI-06 and REQ-CLI-07 themselves trace OK (+doc +impl +unit).

## Acceptance Criteria

All Plan 06.1-05 Task 2 acceptance criteria met:

- `apps/client/src/render/RoomCollision.ts` exists ✓
- `apps/client/src/render/BackgroundRenderer.ts` exists ✓
- `grep -c 'tile_w: 44' RoomCollision.ts` = 2 (≥ 1) ✓
- `grep -c 'tile_h: 40' RoomCollision.ts` = 2 (≥ 1) ✓
- `grep -c '0064-BKA1' BackgroundRenderer.ts` = 3 (≥ 1) ✓
- `grep -c 'dxspeed' BackgroundRenderer.ts` = 4 (≥ 1) ✓
- `grep -c 'scrollFactor' BackgroundRenderer.ts` = 1 (≥ 1) ✓
- `grep -c 'deriveWalkableGrid' RoomRenderer.ts` = 2 (≥ 1) ✓
- `grep -c '0024-TSide1' RoomRenderer.ts` = 1 (≥ 1) ✓
- `grep -c 'walkableGrid' RoomRenderer.ts` = 5 (≥ 2) ✓
- `grep -cE 'public.*walkableGrid' RoomRenderer.ts` = 1 (≥ 1, checker B2 public-accessor) ✓
- `grep -c 'RoomRenderer: missing atlas frame' RoomRenderer.ts` = 2 (≥ 1; D-40 instrumentation) ✓
- Wave 0 background-renderer test GREEN: 7/7 ✓
- Wave 0 room-collision-bottom-edge test GREEN: 5/5 ✓
- `pnpm --filter @rebno/client tsc --noEmit` exits 0 ✓
- `06.1-D40-SPIKE.md` named root cause appears in the commit message (Task 2 commit `2bedc04`) AND in this summary ✓

Task 1 RED acceptance:
- Both test files exist ✓
- background-renderer.test.ts contains literal `55` and `32` and `// [unit->REQ-CLI-07]` ✓
- room-collision-bottom-edge.test.ts contains `bottomEdgeTiles` and `// [unit->REQ-CLI-06]` ✓
- Tests failed at commit `9076f52` (RED) — both files unresolvable until Task 2 added the source modules ✓

## Self-Check: PASSED

Files verified to exist:
- `apps/client/src/render/RoomCollision.ts` — FOUND
- `apps/client/src/render/BackgroundRenderer.ts` — FOUND
- `apps/client/src/__test__/background-renderer.test.ts` — FOUND
- `apps/client/src/__test__/room-collision-bottom-edge.test.ts` — FOUND
- `apps/client/src/render/RoomRenderer.ts` — modified (D-40 instrumentation + walkableGrid + TSide1 + per-tile depth + renderLegacy optional-chain)
- `apps/client/src/scenes/GameScene.ts` — modified (D-40 verify-bail flip at L529)

Commits verified:
- `9076f52` — RED Wave 0 — FOUND on this branch
- `2bedc04` — GREEN Task 2 — FOUND on this branch
