# Empirical Challenger Report: Milestone M2 (Tile Map Rendering & LRU Cache Stress)

**Agent:** `challenger_m2_1`  
**Role:** M2 Performance & Cache Challenger (critic, specialist)  
**Assigned Mission:** Empirically challenge `client/webapp/js/engine/tile_map_renderer.js` and `tools/perf/map_render_benchmark.js`.  
**Verdict:** **REQUEST_CHANGES**  

---

## 1. Observation

### 1.1 Implementation Architecture in `tile_map_renderer.js`
In `client/webapp/js/engine/tile_map_renderer.js`:
- Line 76: `this.MAX_SLOTS = 4;`
- Lines 74-75: `this.CHUNK_PIXEL_W = 1024; this.CHUNK_PIXEL_H = 512;`
- Lines 82-83:
```javascript
this.slots = [];
for (let i = 0; i < this.MAX_SLOTS; i++) this.slots.push(new ChunkSlot(i, this.CHUNK_PIXEL_W, this.CHUNK_PIXEL_H));
```
- Lines 146-159: Viewport culling computes chunk bounding boxes and populates `this.visibleScratch`:
```javascript
const relWx = (cx * 16) - camX, relWy = (cy * 16) - camY;
const screenX = (relWx - relWy) * 32 + halfVpW, screenY = (relWx + relWy) * 16 + halfVpH;
const destX = Math.round(screenX - 512), destY = Math.round(screenY - 16);

if (destX + 1024 >= 0 && destX <= vpW && destY + 512 >= 0 && destY <= vpH) {
  if (this.visibleCount < this.visibleScratch.length) {
    const vs = this.visibleScratch[this.visibleCount++];
    vs.cx = cx; vs.cy = cy; vs.key = (cy << 16) | cx; vs.destX = destX; vs.destY = destY;
  }
}
```
- Lines 162-175: Chunk blitting loop calls `_acquireSlot` and `_bakeChunk` when a chunk is not currently in a slot:
```javascript
for (let i = 0; i < this.visibleCount; i++) {
  const vs = this.visibleScratch[i];
  let slot = this._findSlot(vs.key);
  if (slot) {
    slot.lastUsed = this.currentFrame;
    if (slot.dirty) { this._bakeChunk(slot, vs.cx, vs.cy); slot.dirty = false; }
  } else {
    slot = this._acquireSlot(vs.key, vs.cx, vs.cy);
    this._bakeChunk(slot, vs.cx, vs.cy);
    slot.lastUsed = this.currentFrame; slot.dirty = false;
  }
  ctx.drawImage(slot.canvas, vs.destX, vs.destY);
}
```

### 1.2 Benchmark Bias in `tools/perf/map_render_benchmark.js`
In `tools/perf/map_render_benchmark.js`:
- Lines 79-84:
```javascript
let camWx = 10.0, camWy = 10.0;
for (let f = 0; f < NUM_FRAMES; f++) {
  camWx += 0.08 * Math.cos(f * 0.02);
  camWy += 0.06 * Math.sin(f * 0.02);
```
- The camera position is restricted to a small radius of $\sim 4$ units around $(10, 10)$, located in the extreme top-left corner chunk $(0, 0)$ of the $120 \times 90$ grid. At this corner, out-of-bounds chunks are clamped to `minCx = 0, minCy = 0`, artificially restricting the number of visible chunks to $\le 4$.
- Output from `node tools/perf/map_render_benchmark.js`:
```
Draw Calls / Frame     : 4.4 chunk blits (Target <= 4-6 blits)
Active Canvas RAM      : 8.01 MB / 8.0 MB budget
Status [FPS >= 30]     : ✅ PASS
Status [Blits <= 6]    : ✅ PASS
Status [RAM <= 8 MB]   : ✅ PASS
```

### 1.3 Empirical Discovery: Visible Chunk Distribution Across Map
Running an exhaustive spatial sweep across the $120 \times 90$ map on mobile portrait ($390 \times 844$) and mobile landscape ($880 \times 420$):
- **Mobile Portrait (390 x 844)**:
  - 1 chunk visible: 1 sample
  - 3 chunks visible: 156 samples
  - 4 chunks visible: 204 samples
  - 5 chunks visible: 660 samples
  - 6 chunks visible: 332 samples
  - 7 chunks visible: 1,022 samples
  - 8 chunks visible: 325 samples
  - **Result**: In **86.6%** of map locations ($2,339 / 2,700$), $\text{visibleCount} > 4$ (between 5 and 8 chunks).
- **Mobile Landscape (880 x 420)**:
  - In **79.0%** of map locations ($2,134 / 2,700$), $\text{visibleCount} > 4$ (between 5 and 7 chunks).

### 1.4 Empirical Discovery: Continuous Per-Frame Thrashing with Stationary Camera
Executing a stationary camera test at $(30, 30)$ (open field):
```bash
node -e "
const { TileMapRenderer } = require('./client/webapp/js/engine/tile_map_renderer.js');
const r = new TileMapRenderer();
r.init(new Uint8Array(120*90).fill(1), 120, 90, 1);
const vp = { clientWidth: 390, clientHeight: 844 };
let dummyCtx = { drawImage() {} };
r.render(dummyCtx, { wx: 30, wy: 30 }, vp);
let bakes = 0;
const orig = r._bakeChunk.bind(r);
r._bakeChunk = (s, cx, cy) => { bakes++; orig(s, cx, cy); };
for (let f = 0; f < 10; f++) {
  const b0 = bakes;
  r.render(dummyCtx, { wx: 30, wy: 30 }, vp);
  console.log('Frame ' + f + ': ' + (bakes - b0) + ' bakes, visible=' + r.visibleCount);
}
"
```
**Output observed:**
```
Frame 0: 5 bakes, visible=8
Frame 1: 5 bakes, visible=8
Frame 2: 5 bakes, visible=8
Frame 3: 5 bakes, visible=8
Frame 4: 5 bakes, visible=8
Frame 5: 5 bakes, visible=8
Frame 6: 5 bakes, visible=8
Frame 7: 5 bakes, visible=8
Frame 8: 5 bakes, visible=8
Frame 9: 5 bakes, visible=8
```
- At stationary camera $(30, 30)$, **5.0 chunk re-bakes occur EVERY SINGLE FRAME**.
- Over 50 stationary frames at $(30, 30)$, **250 chunk re-bakes** occurred without a single tile change.
- In contrast, at corner $(10, 10)$, visible chunks = 4, and bakes over 50 stationary frames = 0.

### 1.5 Empirical 10,000 Pan Frames Stress Test Results (`tools/perf/stress_test_lru_cache.js`)
Running `node tools/perf/stress_test_lru_cache.js` across all 48 chunks of the $120 \times 90$ grid:
```
================================================================
  FREEEXILE M2 EMPIRICAL CHALLENGE & LRU CACHE STRESS HARNESS
================================================================
Grid Dimensions          : 120 x 90 (10800 tiles)
Grid Chunk Layout       : 8 cols x 6 rows (48 total chunks)
Renderer Slot Count     : 4 slots
Renderer Canvases Made  : 4 canvases (Target: exactly 4)

--- Test 1: Stationary Camera Cache Invalidation Audit ---
Stationary at Corner (10, 10): visibleChunks=4, bakesOver50Frames=0
Stationary at Field  (30, 30): visibleChunks=8, bakesOver50Frames=250
⚠️ CRITICAL FINDING: LRU Cache Thrashing Bug Confirmed!
   At (30, 30), 8 chunks are visible simultaneously, but only 4 slots exist.
   This causes 5.0 chunk re-bakes EVERY SINGLE FRAME when player is motionless!

--- Test 2: 10,000 Rapid Pan Frames Stress Traversal ---
Execution Duration       : 241.40 ms
Total Frames Tested      : 10000
Chunks Visited           : 48 / 48 total chunks
Total Chunk Bakes        : 23580
Dirty Invalidation Trigs : 137
Canvases Created During  : 0 (Strict requirement: 0)
Draw Calls / Frame       : min=3, avg=5.43, max=8
Visible Chunks / Frame   : min=3, max=8
Active Canvas + Grid RAM : 8.010 MB (Budget: <= 8.02 MB)
Average Frame Time       : 0.0230 ms
Equivalent Average FPS   : 43468.5 FPS (Target >= 30.0)
Percentiles (p50/95/99)  : 0.0210 ms / 0.0478 ms / 0.0699 ms
Max Frame Spike Time     : 1.1396 ms

================================================================
  EVALUATION SUMMARY & CHALLENGE VERDICT
================================================================
  ✅ PASS | All 48 Chunks Traversed in 120x90 Grid (48/48)
  ✅ PASS | Canvas Pool Invariance (Exact 4 Instances, Zero Leaks) (initial: 4, during 10k: 0)
  ✅ PASS | Active Canvas RAM Budget <= 8.02 MB (8.010 MB)
  ✅ PASS | Average FPS >= 30.0 (43468.5 FPS)
  ❌ FAIL | Draw Calls <= 4-6 Chunk Blits per Frame (Average & Max) (avg=5.43, max=8)
  ❌ FAIL | Stationary Camera Zero-Bake Invariant (No Cache Thrashing) (bakes at (10,10)=0, bakes at (30,30)=250)
  ✅ PASS | Dirty Chunk Invalidation Functional (137 invalidations executed)
  ✅ PASS | Adversarial Out-Of-Bounds & NaN Handling (OOB/NaN handled cleanly)
================================================================
Final Empirical Challenger Verdict: REQUEST_CHANGES
================================================================
```

### 1.6 Verification of Existing Python Test Suites
Executing the authoritative Python map system test suites:
```bash
pytest tests/e2e/test_poe2_map_system_e2e.py tests/unit/test_wilderness_map_generator.py -v
```
**Output observed:**
```
============================= 107 passed in 2.32s =============================
```
- `tests/e2e/test_poe2_map_system_e2e.py`: 37/37 tests PASSED.
- `tests/unit/test_wilderness_map_generator.py`: 70/70 tests PASSED.

---

## 2. Logic Chain

1. **Premise 1 (Canvas Pool Size):** `TileMapRenderer` instantiates exactly 4 `ChunkSlot` instances (`MAX_SLOTS = 4`), each with an OffscreenCanvas of $1024 \times 512$ pixels. This was configured to satisfy the RAM limit ($4 \times 1024 \times 512 \times 4\text{ bytes} = 8.0\text{ MB} \le 8.02\text{ MB}$). (Observation 1.1).
2. **Premise 2 (Visible Chunks in Isometric Projection):** In a 2.5D isometric projection ($64 \times 32$ tile dimensions), a standard mobile viewport (e.g. portrait $390 \times 844$ or landscape $880 \times 420$) spans across 6 to 8 chunks of $16 \times 16$ tiles simultaneously. Empirical measurement across 2,700 map coordinates proves that $\text{visibleCount} > 4$ in 86.6% of locations on portrait and 79.0% on landscape. (Observation 1.3).
3. **Premise 3 (Pigeonhole Violation in Slot Allocation):** In `render()`, when $\text{visibleCount} > 4$, the renderer attempts to draw up to 8 chunks into 4 slots. Because there are fewer slots than chunks visible in the single current frame, `_acquireSlot` cannot find an inactive slot (`inVis` is true for all 4 slots). It falls back to evicting an active slot (`bestSlot = this.slots[0]`) and re-bakes the new chunk into it. (Observation 1.1).
4. **Premise 4 (Cache Thrashing Mechanism):** When the camera is stationary or moves slowly, the evicted chunks remain visible on subsequent frames. On the very next frame, those previously evicted chunks are missing from the cache, forcing them to be re-baked again. This cycle repeats continuously every single frame. At $(30, 30)$, 5 chunk re-bakes occur every frame ($250\text{ bakes}$ over 50 frames). (Observation 1.4).
5. **Premise 5 (Direct Acceptance Criteria Violation):** `ORIGINAL_REQUEST.md` (R2 Acceptance Criteria) explicitly mandates:
   > *"Chunk OffscreenCanvas không bị re-render mỗi frame (chỉ re-render khi dirty)"*  
   The current implementation re-renders 5 OffscreenCanvases every frame even when no chunk is dirty, directly violating this requirement.
6. **Premise 6 (Benchmark Defect Masking Thrashing):** `tools/perf/map_render_benchmark.js` evaluated camera positions only within $[6..14] \times [7..13]$ at the corner $(10, 10)$, where out-of-bounds chunks are clamped to 4, completely masking this catastrophic thrashing bug from automated CI. (Observation 1.2).
7. **Premise 7 (Draw Calls Ceiling Exceeded):** The culling logic checks the axis-aligned bounding box $[destX, destX + 1024] \times [destY, destY + 512]$ rather than the isometric diamond. Consequently, chunks whose transparent corners intersect the viewport are blitted even when 0 of their tiles are visible on screen, causing peak draw calls to reach 8 chunk blits per frame (target $\le 4\text{--}6$ blits). (Observation 1.5).

---

## 3. Caveats

1. **Mock Canvas Environment in Node.js:** The benchmarks in `tools/perf/` run inside Node.js using `MockOffscreenCanvas` and `InstrumentableOffscreenCanvas`. In Node.js, 2D canvas drawing calls (`fill`, `stroke`, `moveTo`, `lineTo`) are stubbed, which makes re-baking 5 chunks take only $\sim 0.02\text{ ms}$. On actual mobile hardware with WebGL/Skia/Metal hardware rasterization, re-rasterizing 1,280 isometric tile paths every frame will cause major GPU overhead, severe battery drain, and frame rate drops below 30 FPS.
2. **RAM Budget Invariance Confirmed:** The active canvas pool memory is strictly 8.010 MB, which adheres to the $\le 8.02\text{ MB}$ budget. The pool never exceeds 4 allocated canvas instances.
3. **Python Pipeline Stability:** All Python server and map generation test suites (`test_poe2_map_system_e2e.py` and `test_wilderness_map_generator.py`) pass 100% without regression.
4. **Out-of-Bounds and Invalid Input Robustness:** The renderer successfully handles NaN, Infinity, negative coordinates, and mass invalidation without crashes.

---

## 4. Conclusion

**Verdict: REQUEST_CHANGES**

`client/webapp/js/engine/tile_map_renderer.js` cannot be approved in its current state because the 4-slot LRU cache experiences severe continuous thrashing across 86.6% of the map, re-baking 5 chunks every single frame while the camera is stationary.

### Required Actions for Worker M2:
1. **Fix LRU Cache Thrashing (Mandatory):**
   - The chunk caching architecture must support the number of chunks actually visible on screen without per-frame evictions.
   - **Recommended Mitigation 1 (Tighter Diamond Culling):** Replace the axis-aligned $1024 \times 512$ bounding box culling with isometric diamond intersection. A chunk only needs to be rendered if at least one of its diamond vertices/edges intersects the viewport. This immediately eliminates 2 phantom chunk blits (reducing visible chunks from 8 down to 6).
   - **Recommended Mitigation 2 (Crop Chunk Canvas or Use Shared Atlas):** A $16 \times 16$ chunk has width 1024 and height 512, but more than 50% of the canvas is empty transparent space. If chunks are packed into a shared texture atlas or chunk dimensions are optimized (e.g. $8 \times 8$ chunks on $512 \times 256$ canvases $\rightarrow 0.5\text{ MB}$ each, allowing 16 slots within 8.0 MB), the cache will comfortably hold all visible chunks with 0 thrashing.
   - **Recommended Mitigation 3 (Adjust Slot Count):** If $16 \times 16$ chunks are retained, evaluate increasing `MAX_SLOTS` to $\ge 8$ (or dynamic slot allocation), or negotiate the canvas memory budget appropriately so that all visible chunks remain cached between frames.
2. **Cap Peak Draw Calls to $\le 6$ (Mandatory):**
   - Tighten frustum culling so that maximum chunk blits per frame never exceeds 6.
3. **Update `tools/perf/map_render_benchmark.js` (Mandatory):**
   - Update the benchmark path to sweep through the center of the map (e.g. around $(30, 30)$ or across all chunks) and assert that stationary camera bakes equal zero after initial warm-up.

---

## 5. Verification Method

To independently reproduce all findings and verify future fixes:

1. **Execute Empirical Stress Test Harness:**
   ```bash
   node tools/perf/stress_test_lru_cache.js
   ```
   *Expected Current Output:* Exits with code 1, flagging FAIL on Stationary Camera Zero-Bake Invariant (250 bakes over 50 frames at $(30, 30)$) and Draw Calls ceiling (max 8 blits).

2. **One-Line Stationary Thrashing Reproduction:**
   ```bash
   node -e "const { TileMapRenderer } = require('./client/webapp/js/engine/tile_map_renderer.js'); const r = new TileMapRenderer(); r.init(new Uint8Array(120*90).fill(1), 120, 90, 1); const vp = { clientWidth: 390, clientHeight: 844 }; let d = { drawImage(){} }; r.render(d, { wx: 30, wy: 30 }, vp); let b = 0; const orig = r._bakeChunk.bind(r); r._bakeChunk = (s,cx,cy)=>{b++; orig(s,cx,cy);}; for(let f=0;f<5;f++){ const b0=b; r.render(d,{wx:30,wy:30},vp); console.log('Frame '+f+' bakes: '+(b-b0)); }"
   ```
   *Observed Result:* Prints `Frame 0 bakes: 5`, `Frame 1 bakes: 5`, etc.

3. **Verify Python Map System Test Suites:**
   ```bash
   pytest tests/e2e/test_poe2_map_system_e2e.py tests/unit/test_wilderness_map_generator.py -v
   ```
   *Expected Output:* 107 passed in ~2.3 seconds.
