# Deep-Dive Investigation: Cocos Creator 3.8.x & Apple Metal iOS Integration in FreeExile

**Author**: `explorer_survey_2` (Explorer Subagent)  
**Date**: 2026-10-04  
**Target Milestone**: PBR Power-of-Two (PoT) Texture Atlases for 12 Active Skills & 5 Support Sigils  
**Referenced Workspaces**: `client/cocos/`, `client/webapp/`, `tools/asset_pipeline/`, `client/assets/shaders/`

---

## 1. Executive Summary

This investigation surveys the current rendering architecture, asset pipelines, shader bindings, UV manifest schemas, and platform constraints of **Cocos Creator 3.8.x** and **Apple Metal iOS** within FreeExile.

### Key Discoveries:
1. **Procedural Shaders vs. Baked Binary Atlases Gap**:
   - The current WebApp combat VFX in `client/webapp/js/engine/vfx_renderer.js` relies entirely on procedural 2D Canvas vector drawing (`ctx.arc`, `ctx.stroke`, `ctx.shadowBlur`).
   - In Cocos Creator, `client/cocos/assets/resources/vfx/` currently only contains a single partial atlas (`savage_primal_skills_vfx_atlas.png`) covering only 7 skills/sigils with 4 frames each, leaving 10 active skills and 3 support sigils completely absent.
2. **`SpriteAtlasRenderer.ts` Structural Mismatch**:
   - `SpriteAtlasRenderer.ts` calculates frame positions assuming each clip maps to a fixed row index via hardcoded `clipRowMap` (`idle: 0`, `run: 1`, `attack: 2`, `hurt: 3`, `dodge: 4`, `death: 5`) and `colIndex = frameIndex % cols`.
   - It cannot parse actual character manifests (`char_*_anim_manifest.json`) which use `"animations": ...` and explicit `"frames": { "x", "y", "w", "h", "pivot" }`, nor can it parse sequential row-packed VFX clips where multiple clips share rows or custom row offsets.
   - It also allocates garbage objects (`new Rect`, object literals, array returns) in the frame calculation methods, violating the strict Zero-Allocation Hot Path rule for 120 FPS ProMotion.
3. **No Active VFX Playback Component in Cocos**:
   - `CombatController.ts` emits `EventBus.emit('skillCasted', ...)`, but no script or component in `client/cocos/` listens to this event or triggers sprite animations/VFX quads.
4. **Missing Directory & Asset Structure**:
   - `client/cocos/assets/textures/` does not currently exist. Cocos dynamically loads assets from `client/cocos/assets/resources/`.
5. **Apple Metal iOS & PBR Alignment**:
   - Reference Metal shaders (`SpritePBRNormal.metal`, `elemental_particles.metal`) define Tangent-Space Normal lighting, Blinn-Phong specular, rim lighting, and ghost trail alpha.
   - Cocos `sprite_pbr.effect` implements directional lighting with Tangent-Space Sobel Normal maps and emissive channel, but currently lacks rim lighting and ghost alpha.

---

## 2. Component & Shader Analysis

### 2.1. `SpriteAtlasRenderer.ts` (`client/cocos/assets/scripts/animation/SpriteAtlasRenderer.ts`)

- **Class Identity**: Cocos `Component` decorated with `@ccclass('SpriteAtlasRenderer')`.
- **Default State**:
  - `frameWidth = 160`, `frameHeight = 192`, `cols = 8`
  - Anchor Pivot: `pivotX = 0.5`, `pivotY = 0.90` (standard FreeExile bottom-center grounding pivot).
- **Core Methods**:
  - `initFromManifest(manifest: AtlasManifest)` (lines 58-66):
    Reads `frame_width`, `frame_height`, `cols`, and optional `pivot: [number, number]`.
  - `getFrameSourceRect(...)` (lines 71-101):
    Resolves `targetClipName` checking `manifest.clips[clipName]?.atlas_clip`.
    Looks up `rowIndex` in `clipRowMap`. Falls back to prefix matching, or `directionRowMap[direction]`.
    Computes `colIndex = frameIndex % this.cols`.
    Calculates `sx = colIndex * this.frameWidth`, `sy = rowIndex * this.frameHeight`.
    Returns `new Rect(sx, sy, this.frameWidth, this.frameHeight)`.
  - `getFrameUVs(...)` (lines 106-120):
    Normalizes `getFrameSourceRect` coordinates against `textureWidth` and `textureHeight` to return `[u0, v0, u1, v1]`.
  - `getPivotOffset()` (lines 125-130):
    Returns `{ dx: -this.frameWidth * this.pivotX, dy: -this.frameHeight * this.pivotY }`.

#### Critical Flaws in `SpriteAtlasRenderer.ts`:
1. **Dynamic Memory Allocation in Hot Loop**:
   - Line 100: `return new Rect(...)` allocates a new `Rect` instance on every frame invocation.
   - Line 119: `return [u0, v0, u1, v1]` allocates a new 4-element JS array on every call.
   - Line 126: `return { dx, dy }` creates a new object literal.
   - Across 50 active entities at 120 FPS ($6,000\text{ ticks/sec}$), this creates over $18,000$ short-lived heap allocations per second, triggering JavaScript garbage collector spikes and frame drops on iOS Metal.
2. **Hardcoded Clip-to-Row Assumption**:
   - Lines 82-92 assume each animation clip occupies exactly one row (`rowIndex * frameHeight`).
   - In actual sprite sheets (e.g. `char_sword_master_anim_atlas.png`, $1280 \times 960$), `run` has 8 frames starting at row 0 col 4, wrapping to row 1 col 0. A naive row lookup misaligns `run_0` to row 1 col 0 instead of row 0 col 4.
   - In VFX atlases (e.g. `savage_primal_skills_vfx_atlas.png`), clips have names like `blood_drain_aoe`, `chain_lightning_arc`. `clipRowMap` does not contain these keys, so they all default to row 0.
3. **No Integration with Cocos `Sprite` or Material**:
   - `SpriteAtlasRenderer` does not hold a reference to `cc.Sprite` or `cc.MeshRenderer`. It is purely a disconnected math calculation helper. It does not update sprite frames, UV rects, or material parameters.

---

### 2.2. `sprite_pbr.effect` (`client/cocos/assets/resources/shaders/sprite_pbr.effect`)

- **Syntax & Passes**:
  - Cocos Creator 3.8.x Effect asset with two techniques/passes: `opaque` and `transparent` (alpha blending `src_alpha / one_minus_src_alpha`).
- **Texture Bindings**:
  - `mainTexture`: Diffuse/Albedo sprite atlas texture (default `white`).
  - `normalMap`: Tangent-space normal map (default `normal` = flat normal $[0.5, 0.5, 1.0, 1.0]$).
  - `emissiveMap`: Emissive mask texture (default `black`).
- **Uniform Parameters**:
  - `diffuseColor`: Tint color (vec4, default $[1.0, 1.0, 1.0, 1.0]$).
  - `emissiveColor`: Emissive glow color (vec4, default $[0.0, 0.0, 0.0, 1.0]$).
  - `roughness`: Microfacet roughness (float, default $0.6$).
  - `lightDir`: Key directional light vector ($[0.577, 0.577, 0.577, 0.0]$ representing isometric $30^\circ$ downward light).
  - `lightColor`: Directional light intensity and tint ($[1.0, 0.95, 0.85, 1.0]$).
- **Lighting Model in Fragment Shader** (lines 73-102):
  ```glsl
  vec4 albedo = texture(mainTexture, v_uv) * v_color * diffuseColor;
  if (albedo.a < 0.01) discard;

  vec3 normalSample = texture(normalMap, v_uv).rgb * 2.0 - 1.0;
  vec3 normal = normalize(normalSample);

  vec3 L = normalize(lightDir.xyz);
  float NdotL = max(dot(normal, L), 0.0);
  vec3 diffuse = NdotL * lightColor.rgb;

  vec3 V = vec3(0.0, 0.0, 1.0);
  vec3 H = normalize(L + V);
  float NdotH = max(dot(normal, H), 0.0);
  float specPower = (1.0 - roughness) * 64.0 + 8.0;
  float specular = pow(NdotH, specPower) * (1.0 - roughness);

  vec4 emissiveSample = texture(emissiveMap, v_uv);
  vec3 emissive = emissiveSample.rgb * emissiveColor.rgb;

  vec3 ambient = vec3(0.35, 0.32, 0.40);
  vec3 finalRgb = albedo.rgb * (ambient + diffuse) + specular * lightColor.rgb + emissive;
  ```
- **Comparison with Apple Metal Native Shader (`SpritePBRNormal.metal`)**:
  - Both use tangent-space unpacked normal maps ($N = \text{sample} \times 2 - 1$) and Blinn-Phong specular.
  - `SpritePBRNormal.metal` additionally implements:
    1. **Rim Lighting**: $N \cdot V$ grazing-angle rim factor ($\text{rimFactor} = (1.0 - N \cdot V)^{\text{rimPower}}$) creating a silhouette edge glow essential for visibility in dark dungeon environments.
    2. **Ghost Alpha Uniform**: `ghostAlpha` for Phantom Evasion (`huyen_anh_bo`) 0.25s afterimage fading.
  - Recommendation: Enhance `sprite_pbr.effect` with optional rim lighting properties (`rimColor`, `rimPower`) to achieve parity with the Metal native shader.

---

## 3. UV Manifest & Frame Descriptor Schema Analysis

Four divergent manifest formats exist across the repository:

| Schema Name | Primary Example | Key Properties | Strengths | Limitations |
| :--- | :--- | :--- | :--- | :--- |
| **Schema A: Character Manifest** | `char_sword_master_anim_manifest.json` | `entity_id`, `texture_width`, `texture_height`, `frame_width`, `frame_height`, `kinematics`, `frames: Record<string, {x, y, w, h, pivot}>`, `animations: Record<string, {frames, fps, loop}>` | Exhaustive; gives exact pixel coordinates for every frame. | Complex; uses `animations` instead of `clips`. |
| **Schema B: SpriteAtlasRenderer Interface** | `SpriteAtlasRenderer.ts: AtlasManifest` | `name`, `frame_width`, `frame_height`, `cols?`, `pivot?`, `clips: Record<string, {frames, atlas_clip?, fps?}>` | Minimalist and lightweight. | Assumes single row per clip; cannot handle grid overflow. |
| **Schema C: VFX Atlas Manifest** | `savage_primal_skills_vfx_atlas.json` | `name`, `frame_width: 128`, `frame_height: 128`, `cols: 8`, `pivot: [0.5, 0.9]`, `clips: Record<string, {frames, fps, duration_ms}>` | Tailored for VFX sequences; Power-of-Two. | Does not specify per-clip row offsets or UV rects directly. |
| **Schema D: Static Skills Metadata** | `martial_skills_metadata.json` | `version`, `atlas_width: 1024`, `atlas_height: 1024`, `skills: Record<string, {uv: [u0,v0,u1,v1], pixel_rect: [x,y,w,h], pivot, fps, element}>` | Explicit normalized UVs and element tags. | Designed for static icons (1 frame per skill), not animated flipbooks. |

### Schema Reconciliation Requirement:
`SpriteAtlasRenderer.ts` must provide a **Unified Atlas Descriptor Adapter** that:
1. Recognizes both `clips` and `animations` as clip collections.
2. If `frames` dictionary exists with explicit `x, y, w, h`, it looks up pixel rects directly in $O(1)$ time without mathematical guessing.
3. If `frames` dictionary is omitted, it reads `clip.row` (if defined) or uses sequential packing index:
   $$\text{globalIndex} = \sum_{c < \text{target}} \text{len}(c) + \text{frameIndex}$$
   $$\text{col} = \text{globalIndex} \pmod{\text{cols}},\quad \text{row} = \lfloor \text{globalIndex} / \text{cols} \rfloor$$
4. Caches pre-calculated normalized UVs `[u0, v0, u1, v1]` upon `initFromManifest()`, eliminating runtime division during the 120 FPS hot path.

---

## 4. Directory Structure & Asset Inventory

### 4.1. `client/cocos/assets/resources/vfx/`
Contains:
- `savage_primal_skills_vfx_atlas.png` ($1024 \times 1024$, 51.8 KB)
- `savage_primal_skills_vfx_atlas_normal.png` ($1024 \times 1024$, 212.8 KB)
- `savage_primal_skills_vfx_atlas.json` (1.87 KB)

**Coverage Status**:
Only 7 skills/sigils exist in this atlas:
1. `blood_drain_aoe` (Skill 1011 - Huyết Ma Khấp Huyết Kình)
2. `bone_totem_summon` (Skill 1012 - Man Cốt Triệu Hồn Đồ)
3. `fire_whirlwind_slash` (Skill 1007 - Liệt Hỏa Toàn Phong Trảm)
4. `ground_slam_crater` (Skill 1009 - Kim Cang Trụy Địa Chấn)
5. `ghost_trail_evasion` (Skill 1099 - Huyễn Ảnh Bộ)
6. `multi_projectile_split` (Sigil 2001 - Phân Quang Đa Hóa Ấn)
7. `chain_lightning_arc` (Sigil 2005 - Lôi Đình Xâu Chuỗi Ấn)

**Missing Active Skills (10 Active Skills)**:
- `cuu_tieu_loi_kiem` (1001) - Thunder Piercing Greatsword
- `thanh_phong_van_kiem_vu` (1002) - Thunder Fan of Swords
- `u_minh_huyet_doc_cham` (1003) - Toxic Corrosion Needles
- `thien_ma_boc_doc_kinh` (1004) - Toxic Nova Detonation
- `bang_phach_han_quang_tran` (1005) - Glacial Freeze Seal Arena
- `bang_tien_xuyen_tam` (1006) - Piercing Frost Arrow
- `liet_diem_boc_khi_tram` (1008) - Conflagration Magma Cleave
- `kim_cang_bat_hoai_the` (1010) - Golden Vajra Body Barrier
- `primary_strike` (1000) - Basic Martial Slash

**Missing Support Sigils (3 Support Sigils)**:
- `aoe_expansion` (2002 - Bộc Phá Khuếch Đại Ấn)
- `blood_magic` (2004 - Huyết Khí Quy Tông Ấn)
- `rapid_cast` (2007 - Cuồng Phong Liên Hoàn Ấn)

### 4.2. `client/cocos/assets/textures/`
- **Status**: Directory does NOT exist.
- **Convention in Cocos Creator**:
  - `assets/resources/`: For assets loaded at runtime via `resources.load<T>('vfx/...', ...)`.
  - `assets/textures/`: Statically assigned textures referenced directly in Scene or Prefabs.
  - For dynamic skill VFX spawned on demand during combat, files must reside inside `assets/resources/vfx/`. If static references are also desired, a symlink or mirror in `assets/textures/` can be created.

---

## 5. Apple Metal iOS Platform Specifications

| Constraint / Metric | Target Requirement | Current Implementation Status | Evaluation |
| :--- | :--- | :--- | :--- |
| **Texture Format** | ASTC 4x4 (8 bpp) on iOS Metal / WebP 80% on Web | WebP compressor in `tools/asset_pipeline/texture_compressor.py`; PNG source files in repo. | Compliant for source; needs build-time ASTC pack for iOS release. |
| **Power-of-Two (PoT)** | $1024 \times 1024$ or $2048 \times 2048$ | `savage_primal_skills_vfx_atlas.png` is $1024 \times 1024$. Character atlases are $1280 \times 960$ (Non-PoT). | VFX atlas is compliant; Character atlases need PoT rebaking ($2048 \times 2048$). |
| **Frame Rate** | 120 FPS ProMotion ($8.33\text{ ms/frame}$) | Verified in `ios_metal_profiler.py` (simulated avg $2.58\text{ ms}$). | Architecture compliant; requires zero-alloc enforcement in TS. |
| **VRAM Ceiling** | $< 450\text{ MB}$ total footprint | Measured peak in benchmark: $268.4\text{ MB}$. ASTC 4x4 uses $1.05\text{ MB}$ per $1024\times 1024$ atlas. | Excellent headroom. |
| **Hot Path Allocations** | $0$ heap allocations per frame | `SpriteAtlasRenderer.ts` currently allocates `Rect`, arrays, and objects every call. | **CRITICAL GAP**: Must eliminate allocations. |
| **Alpha Bleeding** | Clean alpha; no black fringe on dark backgrounds | Requires Dilation Padding on RGB channels before saving PNG/ASTC. | Must be enforced in python asset producer. |
| **Normal Map Tangent Space** | Blue channel mean $\ge 128.0$ ($Z \ge 0$) | Sobel filter in `m4_normal_utils.py` and `produce_all_missing_skills_vfx.py` produces blue mean $> 200$. | Verified compliant. |

---

## 6. Gaps & Concrete Architectural Proposals

### 6.1. Gap 1: Zero-Allocation Optimization for `SpriteAtlasRenderer.ts`
**Root Cause**: Allocating `Rect` and tuples inside `getFrameSourceRect` and `getFrameUVs`.  
**Proposed Solution**:
```typescript
export class SpriteAtlasRenderer extends Component {
  // Pre-allocated scratch instances
  private static readonly _scratchRect: Rect = new Rect();
  private static readonly _scratchUVs: [number, number, number, number] = [0, 0, 1, 1];
  private static readonly _scratchOffset: { dx: number; dy: number } = { dx: 0, dy: 0 };

  // Pre-baked UV cache to avoid runtime arithmetic: Map<frameKey, [u0, v0, u1, v1]>
  private readonly _uvCache: Map<string, [number, number, number, number]> = new Map();
  ...
}
```

### 6.2. Gap 2: Unified Manifest Support in `SpriteAtlasRenderer.ts`
**Root Cause**: Inability to parse character manifests (`animations`) or explicit frame coords (`frames`).  
**Proposed Solution**:
Add `frames?: Record<string, { x: number; y: number; w: number; h: number; pivot?: [number, number] }>` and support both `clips` and `animations` keys in `AtlasManifest`. Upon `initFromManifest`, if `frames` exists, pre-bake all normalized UVs into `_uvCache`.

### 6.3. Gap 3: Missing Skill VFX Playback Component in Cocos
**Root Cause**: No listener for `skillCasted` in Cocos.  
**Proposed Solution**:
Create `client/cocos/assets/scripts/combat/SkillVfxPlayer.ts`:
- Component attached to Player or Combat Root node.
- Subscribes to `EventBus.on('skillCasted', this.onSkillCasted, this)`.
- Instantiates/reuses pooled VFX sprite nodes with `sprite_pbr.effect`.
- Plays the 4-frame flipbook animation sequence using `savage_primal_skills_vfx_atlas`.
- Applies anchor pivot `[0.5, 0.90]` and directional rotation matching player heading.
- Returns node to pool on completion.

### 6.4. Gap 4: Full 12 Active Skills & 5 Support Sigils VFX Atlas Generation
**Root Cause**: `produce_all_missing_skills_vfx.py` only implements 7 skills.  
**Proposed Solution**:
Expand Python generator to synthesize frame strips for all 12 active skills + 5 support sigils:
- Pack into a single Power-of-Two $2048 \times 2048$ atlas (or two $1024 \times 1024$ atlases).
- Apply RGB Dilation Padding to eliminate dark halo artifacts.
- Generate Tangent-Space Sobel Normal map with Blue channel mean $\ge 128.0$.
- Output manifest containing explicit `frames` pixel coordinates and `clips` sequence definitions.
- Synchronize to both `client/cocos/assets/resources/vfx/` and `client/webapp/assets/vfx/`.
