# Root Cause Analysis & Fix Strategy: UV Overflow in Monster & Character Scaffolding

**Author**: `explorer_rem_1` (Explorer Subagent)  
**Date**: 2026-10-04  
**Target File**: `tools/asset_pipeline/monster_character_pipeline_scaffold.py`  
**Related Files**:  
- `client/cocos/assets/resources/monsters/archetypes/*/*_anim_manifest.json`  
- `client/cocos/assets/resources/characters/*/*_anim_manifest.json`  
- `client/cocos/assets/scripts/animation/SpriteAtlasRenderer.ts`  
- `tests/e2e_cocos/test_vfx_texture_atlas_pipeline_e2e.py`  

---

## 1. Executive Summary

In `tools/asset_pipeline/monster_character_pipeline_scaffold.py`, normalized vertical texture coordinates ($V$) overflow the valid range $[0.0, 1.0]$, reaching up to **$V = 3.75$** for monsters and **$V = 7.50$** for characters. This occurs because the tool hardcodes `"textureWidth": 2048, "textureHeight": 2048` and divides frame coordinates by `2048.0`, yet advances row indices across 48 rows ($48 \times 160\text{ px} = 7,680\text{ px}$) for monsters and 80 rows ($80 \times 192\text{ px} = 15,360\text{ px}$) for characters.

Mathematically, 216 monster frames ($5,529,600\text{ px}^2$) and 448 character frames ($13,762,560\text{ px}^2$) cannot fit on a single $2048 \times 2048$ sheet ($4,194,304\text{ px}^2$) without downscaling or multi-sheet pagination. The recommended, minimally disruptive fix is **Dynamic Power-of-Two (PoT) Texture Dimension Scaling**: declaring `"textureWidth": 2048, "textureHeight": 8192` for monsters and `"textureWidth": 2048, "textureHeight": 16384` (or $4096 \times 8192$) for characters, dynamically normalizing UV coordinates by `(tex_w, tex_h)` so that $0.0 \le u_0 < u_1 \le 1.0$ and $0.0 \le v_0 < v_1 \le 0.9375$ across $100\%$ of frames.

---

## 2. Codebase & Grid Layout Inspection

### 2.1 Monster Archetype Grid Layout (`scaffold_monsters`)
- **Inspection Path**: `tools/asset_pipeline/monster_character_pipeline_scaffold.py:65-135`
- **Archetype Count**: 10 archetypes (`mob_risen_skeleton`, `mob_feral_hellhound`, `mob_blood_crawler`, `mob_bramble_treant`, `mob_corrupted_raptor`, `mob_flesh_abomination`, `mob_ironhide_behemoth`, `mob_primal_cannibal`, `mob_shadow_wraith`, `mob_tomb_lord`).
- **Frame Dimensions**: $W_f = 160\text{ px}, H_f = 160\text{ px}$.
- **Actions & Frame Counts**:
  - `idle`: 4 frames ($\text{fps}=6, \text{loop}=\text{True}$)
  - `walk`: 6 frames ($\text{fps}=10, \text{loop}=\text{True}$)
  - `attack`: 6 frames ($\text{fps}=14, \text{loop}=\text{False}, \text{hit\_frame}=2$)
  - `hurt`: 3 frames ($\text{fps}=12, \text{loop}=\text{False}$)
  - `death`: 5 frames ($\text{fps}=10, \text{loop}=\text{False}$)
  - `stun`: 3 frames ($\text{fps}=8, \text{loop}=\text{True}$)
  - Total actions per archetype: $6$
- **Directions**: 8 canonical isometric directions (`S`, `SW`, `W`, `NW`, `N`, `NE`, `E`, `SE`).
- **Total Clips**: $6\text{ actions} \times 8\text{ directions} = 48\text{ clips}$.
- **Total Frames per Archetype**: $(4 + 6 + 6 + 3 + 5 + 3) \times 8 = 27 \times 8 = 216\text{ frames}$.
- **Row Index Logic** (lines 91–118):
  - `row_idx = 0` is initialized before iterating actions.
  - For each `(action, direction)` clip, `row_idx` increments by 1 (`row_idx += 1`).
  - Column coordinate: `col = f % 8`, `x = col * 160`.
  - Row coordinate: `y = (row_idx + (f // 8)) * 160`. Because $\text{frameCount} \le 6 < 8$, `f // 8 == 0` for all frames, so $y = row\_idx \times 160$.
  - Row index $row\_idx$ spans from $0$ to $47$ ($48\text{ rows}$).
  - Maximum $y$: $y_{max} = 47 \times 160 = 7,520\text{ px}$.
  - Bottom edge of last frame: $y_{max} + H_f = 7,680\text{ px}$.
- **Declared Texture Size** (lines 82–83):
  `"textureWidth": 2048, "textureHeight": 2048`
- **UV Calculation** (line 107):
  ```python
  "uv": [x / 2048.0, y / 2048.0, (x + 160) / 2048.0, (y + 160) / 2048.0]
  ```

### 2.2 Exile Character Grid Layout (`scaffold_characters`)
- **Inspection Path**: `tools/asset_pipeline/monster_character_pipeline_scaffold.py:138-198`
- **Character Count**: 6 classes (`char_sword_master`, `char_sword_maiden`, `char_feral_berserker`, `char_wild_archer`, `char_glacial_lancer`, `char_shadow_assassin`).
- **Frame Dimensions**: $W_f = 160\text{ px}, H_f = 192\text{ px}$.
- **Actions & Frame Counts**:
  - `idle`: 4 frames
  - `run`: 8 frames
  - `attack_slash`: 6 frames
  - `attack_thrust`: 6 frames
  - `attack_slam`: 6 frames
  - `attack_shoot`: 6 frames
  - `skill_whirlwind`: 6 frames
  - `dodge`: 6 frames
  - `hurt`: 3 frames
  - `death`: 5 frames
  - Total actions per class: $10$
- **Directions**: 8 canonical isometric directions.
- **Total Clips**: $10\text{ actions} \times 8\text{ directions} = 80\text{ clips}$.
- **Total Frames per Class**: $(4 + 8 + 6 \times 6 + 3 + 5) \times 8 = 56 \times 8 = 448\text{ frames}$.
- **Row Index Logic** (lines 162–190):
  - `row_idx = 0` is initialized before iterating actions.
  - For each `(action, direction)` clip, `row_idx += 1`.
  - Column coordinate: `col = f % 8`, `x = col * 160`.
  - Row coordinate: $y = row\_idx \times 192$ (since $f \le 7 < 8$, $f // 8 == 0$).
  - Row index $row\_idx$ spans from $0$ to $79$ ($80\text{ rows}$).
  - Maximum $y$: $y_{max} = 79 \times 192 = 15,168\text{ px}$.
  - Bottom edge of last frame: $y_{max} + H_f = 15,360\text{ px}$.
- **Declared Texture Size** (lines 153–154):
  `"textureWidth": 2048, "textureHeight": 2048`
- **UV Calculation** (line 178):
  ```python
  "uv": [x / 2048.0, y / 2048.0, (x + 160) / 2048.0, (y + 192) / 2048.0]
  ```

---

## 3. Mathematical Proof & Quantification of Defect

### 3.1 Why V > 1.0 Occurs
In computer graphics, normalized texture coordinates $(U, V)$ must satisfy:
$$0.0 \le u_0 \le u_1 \le 1.0, \quad 0.0 \le v_0 \le v_1 \le 1.0$$
where:
$$u_0 = \frac{x}{W_{tex}}, \quad u_1 = \frac{x + w}{W_{tex}}, \quad v_0 = \frac{y}{H_{tex}}, \quad v_1 = \frac{y + h}{H_{tex}}$$

When $H_{tex}$ is declared as $2048$:
- **Monsters**:
  At row $k = 12$: $y = 12 \times 160 = 1920\text{ px}$, $v_1 = (1920 + 160) / 2048.0 = 2080 / 2048 = 1.015625 > 1.0$.  
  At row $k = 47$: $y = 47 \times 160 = 7520\text{ px}$, $v_1 = (7520 + 160) / 2048.0 = 7680 / 2048 = 3.750000 > 1.0$.  
- **Characters**:
  At row $k = 10$: $y = 10 \times 192 = 1920\text{ px}$, $v_1 = (1920 + 192) / 2048.0 = 2112 / 2048 = 1.031250 > 1.0$.  
  At row $k = 79$: $y = 79 \times 192 = 15168\text{ px}$, $v_1 = (15168 + 192) / 2048.0 = 15360 / 2048 = 7.500000 > 1.0$.

### 3.2 Quantitative Impact
| Entity Category | Total Frames | In-Bounds ($v_1 \le 1.0$) | Out-of-Bounds ($v_1 > 1.0$) | Defect Rate | Min V | Max V |
|---|---|---|---|---|---|---|
| **Monster Archetypes** (each of 10) | 216 | 56 frames (rows 0–11) | **160 frames** (rows 12–47) | **74.07%** | 0.078125 | **3.750000** |
| **Exile Characters** (each of 6) | 448 | 48 frames (rows 0–9) | **400 frames** (rows 10–79) | **89.29%** | 0.093750 | **7.500000** |
| **Total Across Pipeline** | 4,848 | 1,048 | **3,800 frames** | **78.38%** | 0.078125 | **7.500000** |

### 3.3 Impossibility of Fitting on a Single 2048x2048 Sheet
Can all frames of a monster archetype or exile character fit on a single $2048 \times 2048$ texture under ANY 2D bin-packing algorithm without downscaling?

**Proof**:
1. Let $Area_{tex} = 2048 \times 2048 = 4,194,304\text{ px}^2$.
2. For a Monster Archetype:
   $$Area_{monster} = 216 \times (160 \times 160) = 216 \times 25,600 = 5,529,600\text{ px}^2$$
   $$\frac{Area_{monster}}{Area_{tex}} = \frac{5,529,600}{4,194,304} \approx 1.31834$$
   The frames require $131.83\%$ of the available texture area. By Conservation of Area, no packing arrangement can fit $5.53\text{ Mpx}$ into a $4.19\text{ Mpx}$ texture.
3. For an Exile Character Class:
   $$Area_{character} = 448 \times (160 \times 192) = 448 \times 30,720 = 13,762,560\text{ px}^2$$
   $$\frac{Area_{character}}{Area_{tex}} = \frac{13,762,560}{4,194,304} = 3.28125$$
   The frames require $328.125\%$ of the available texture area. Minimum sheets required: $\lceil 3.28125 \rceil = 4\text{ sheets}$.

---

## 4. Fix Strategy Formulation & Evaluation

Three distinct architectural strategies can resolve the UV overflow defect:

### Strategy 1: Dynamic Power-of-Two (PoT) Texture Dimensions (Recommended)
Preserve the standard 1-row-per-clip grid ($cols = 8$). Scale the declared texture height to the smallest Power-of-Two integer $H_{tex} \ge H_{required}$:
$$H_{tex} = 2^{\lceil \log_2(N_{rows} \times H_f) \rceil}$$

- **Monsters**:
  - $N_{rows} = 48, \quad H_f = 160\text{ px} \implies H_{required} = 48 \times 160 = 7,680\text{ px}$.
  - $H_{tex} = 2^{\lceil \log_2(7680) \rceil} = 2^{13} = 8,192\text{ px}$.
  - $W_{tex} = 2,048\text{ px}$ (since $8 \times 160 = 1,280 \le 2048$).
  - Manifest declarations: `"textureWidth": 2048, "textureHeight": 8192`.
  - Max coordinates: $u_1 = 960 / 2048.0 = 0.46875 \le 1.0$, $v_1 = 7680 / 8192.0 = 0.9375 \le 1.0$.
- **Characters**:
  - $N_{rows} = 80, \quad H_f = 192\text{ px} \implies H_{required} = 80 \times 192 = 15,360\text{ px}$.
  - $H_{tex} = 2^{\lceil \log_2(15360) \rceil} = 2^{14} = 16,384\text{ px}$.
  - $W_{tex} = 2,048\text{ px}$ (since $8 \times 160 = 1,280 \le 2048$).
  - Manifest declarations: `"textureWidth": 2048, "textureHeight": 16384`.
  - Max coordinates: $u_1 = 1280 / 2048.0 = 0.6250 \le 1.0$, $v_1 = 15360 / 16384.0 = 0.9375 \le 1.0$.
  - *Alternative for Characters*: $W_{tex} = 4096, H_{tex} = 8192$ using 16 columns (packing 2 directions per row, resulting in 40 rows). Max $u_1 = 2560 / 4096.0 = 0.625 \le 1.0$, max $v_1 = 7680 / 8192.0 = 0.9375 \le 1.0$.

**Evaluation**:
- **Pros**:
  - 100% backward-compatible with Cocos Creator `SpriteAtlasRenderer.ts` (which expects a single texture per manifest).
  - Preserves intuitive 1:1 mapping between `clip_key = f"{action}_{direction}"` and grid rows.
  - Zero out-of-bounds frames across all 16 manifests.
  - Directly satisfies the reviewer's automated verification script.
- **Cons**:
  - $2048 \times 16384$ has a 16K vertical dimension; while Apple Metal and modern desktop GPUs support 16K textures, mobile WebGL 2.0 implementations may cap `MAX_TEXTURE_SIZE` at 8192. (Mitigated if $4096 \times 8192$ is chosen).

---

### Strategy 2: Multi-Page Atlas Pagination into 2048x2048 Sheets
Enforce a hard limit of $2048 \times 2048$ per texture sheet. Paginate clips across multiple sheets, resetting $row\_in\_sheet = 0$ at each sheet boundary:

- **Monsters**:
  - Rows per sheet: $\lfloor 2048 / 160 \rfloor = 12\text{ rows}$.
  - Sheets required: $\lceil 48 / 12 \rceil = 4\text{ sheets}$ (`sheet_0` to `sheet_3`).
  - Row in sheet: $row\_in\_sheet = k \pmod{12} \in [0, 11]$.
  - Coordinates: $y = row\_in\_sheet \times 160 \le 1760\text{ px}$.
  - UVs: $v_1 = (y + 160) / 2048.0 \le 1920 / 2048.0 = 0.9375 \le 1.0$.
- **Characters**:
  - Rows per sheet: $\lfloor 2048 / 192 \rfloor = 10\text{ rows}$.
  - Sheets required: $\lceil 80 / 10 \rceil = 8\text{ sheets}$ (`sheet_0` to `sheet_7`).
  - Row in sheet: $row\_in\_sheet = k \pmod{10} \in [0, 9]$.
  - Coordinates: $y = row\_in\_sheet \times 192 \le 1728\text{ px}$.
  - UVs: $v_1 = (y + 192) / 2048.0 \le 1920 / 2048.0 = 0.9375 \le 1.0$.

**Evaluation**:
- **Pros**:
  - Every individual texture is strictly $2048 \times 2048$ (PoT and WebGL mobile-safe).
- **Cons**:
  - Requires breaking changes to the JSON manifest schema: introducing `"sheets": [...]` or `"texture": "..."` per frame.
  - Requires refactoring `SpriteAtlasRenderer.ts` in Cocos to manage multi-texture binding and sheet swapping during animation playback.

---

### Strategy 3: Action-Based Atlas Scaffolding (1 Sheet per Action)
Split the archetype/character into separate manifests per action (`genus_idle_anim_manifest.json`, `genus_walk_anim_manifest.json`, etc.):
- Since each action has 8 directions, each action requires exactly 8 rows.
- Monster: $8 \times 160 = 1280\text{ px} \le 2048\text{ px}$.
- Character: $8 \times 192 = 1536\text{ px} \le 2048\text{ px}$.
- Every action sheet easily fits in $2048 \times 2048$ with $row\_idx \in [0, 7]$, max $v_1 \le 0.75$.

**Evaluation**:
- **Pros**: Clean modularity per action state.
- **Cons**: Multiplies file count by 6x–10x (60 monster manifests, 60 character manifests) and conflicts with existing test assertions expecting `f"{genus}_anim_manifest.json"`.

---

## 5. Proposed Implementation Specification

### 5.1 Helper Function: `next_power_of_two`
Add at module scope in `tools/asset_pipeline/monster_character_pipeline_scaffold.py`:
```python
def next_power_of_two(n: int) -> int:
    """Calculates the smallest power of two greater than or equal to n."""
    if n <= 0:
        return 1
    return 1 << (n - 1).bit_length()
```

### 5.2 Patch for `scaffold_monsters`
In `scaffold_monsters()`:
1. Dynamically compute total grid rows:
   ```python
   total_rows = len(archetype["actions"]) * len(CANONICAL_DIRECTIONS)
   total_h = total_rows * archetype["frame_height"]
   total_w = 8 * archetype["frame_width"]
   tex_w = next_power_of_two(total_w)    # 2048
   tex_h = next_power_of_two(total_h)    # 8192
   ```
2. Set `"textureWidth": tex_w, "textureHeight": tex_h` and `"texture_width": tex_w, "texture_height": tex_h` in `manifest`.
3. Normalize UVs using dynamic floats:
   ```python
   "uv": [
       x / float(tex_w),
       y / float(tex_h),
       (x + archetype["frame_width"]) / float(tex_w),
       (y + archetype["frame_height"]) / float(tex_h)
   ]
   ```
4. Set `"target_resolution": [tex_w, tex_h]` in `pipeline_cfg`.

### 5.3 Patch for `scaffold_characters`
In `scaffold_characters()`:
1. Dynamically compute total grid rows:
   ```python
   total_rows = len(char_info["actions"]) * len(CANONICAL_DIRECTIONS)
   total_h = total_rows * char_info["frame_height"]
   total_w = 8 * char_info["frame_width"]
   tex_w = next_power_of_two(total_w)    # 2048
   tex_h = next_power_of_two(total_h)    # 16384
   ```
2. Set `"textureWidth": tex_w, "textureHeight": tex_h` in `manifest`.
3. Normalize UVs using dynamic floats:
   ```python
   "uv": [
       x / float(tex_w),
       y / float(tex_h),
       (x + char_info["frame_width"]) / float(tex_w),
       (y + char_info["frame_height"]) / float(tex_h)
   ]
   ```

---

## 6. Verification and Test Enforcement

### 6.1 Verification Commands
1. **Regenerate Scaffolds**:
   ```bash
   python tools/asset_pipeline/monster_character_pipeline_scaffold.py
   ```
2. **Reviewer Verification Script (Assert 0 out-of-bounds frames)**:
   ```bash
   python -c "
   import json, pathlib
   for cat in ['monsters/archetypes', 'characters']:
       for p in pathlib.Path(f'client/cocos/assets/resources/{cat}').glob('*/*_anim_manifest.json'):
           m = json.load(open(p, encoding='utf-8'))
           bad = [f['uv'] for f in m['frames'].values() if f['uv'][3] > 1.0 or f['uv'][2] > 1.0]
           max_v = max(f['uv'][3] for f in m['frames'].values())
           print(p.parent.name, 'Bad frames:', len(bad), 'Max V:', round(max_v, 4))
   "
   ```
   *Expected output*: `Bad frames: 0`, `Max V: 0.9375` for all 16 manifests.

### 6.2 Test Suite Strengthening
Add explicit assertions to `tests/e2e_cocos/test_vfx_texture_atlas_pipeline_e2e.py` (Tier 1):
```python
def test_t1_monster_and_character_manifest_uv_bounds(self) -> None:
    """Verifies that 100% of frames across all scaffolded manifests satisfy 0.0 <= u, v <= 1.0."""
    monsters_dir = REPO_ROOT / "client" / "cocos" / "assets" / "resources" / "monsters" / "archetypes"
    for manifest_path in monsters_dir.glob("*/*_anim_manifest.json"):
        with open(manifest_path, "r", encoding="utf-8") as f:
            manifest = json.load(f)
        for frame_id, frame in manifest.get("frames", {}).items():
            u0, v0, u1, v1 = frame["uv"]
            assert 0.0 <= u0 <= u1 <= 1.0, f"Monster {manifest_path.name} frame {frame_id} U out of bounds: [{u0}, {u1}]"
            assert 0.0 <= v0 <= v1 <= 1.0, f"Monster {manifest_path.name} frame {frame_id} V out of bounds: [{v0}, {v1}]"

    characters_dir = REPO_ROOT / "client" / "cocos" / "assets" / "resources" / "characters"
    for manifest_path in characters_dir.glob("*/*_anim_manifest.json"):
        with open(manifest_path, "r", encoding="utf-8") as f:
            manifest = json.load(f)
        for frame_id, frame in manifest.get("frames", {}).items():
            u0, v0, u1, v1 = frame["uv"]
            assert 0.0 <= u0 <= u1 <= 1.0, f"Character {manifest_path.name} frame {frame_id} U out of bounds: [{u0}, {u1}]"
            assert 0.0 <= v0 <= v1 <= 1.0, f"Character {manifest_path.name} frame {frame_id} V out of bounds: [{v0}, {v1}]"
```
