Skip to content

Debugging model / texture / chroma issues ​

Field guide for investigating "the model looks wrong" bugs in Khada. Written for AI agents (and humans) who need to know where to look and what commands to run. Not a list of known bugs — a list of the tools, URLs, and inspection techniques that let you isolate any new one.

The pipeline at a glance ​

Every pixel a user sees passed through this chain. When something looks wrong, the question is always at which stage did it diverge.

.wad.client  →  C# extractor  →  standalone PNGs + model.glb
                                      ↓
                        settings/manual-files/…  ──overlay──►  same folder
                                      ↓
        standardizeModelStructure → dedupModel → injectModelFixes → GenerateAR
                                      ↓
                           separateTexture  →  updated standalone PNGs
                                      ↓
                    createCompressedModel (gltfpack -cc)  →  model-compressed.wasm
                                      ↓
                              R2 upload (khada-staging / khada-production)
                                      ↓
                              cdn.staging.modelviewer.lol  or  cdn.modelviewer.lol
                                      ↓
                     Three.js engine  →  handleFlipbook / handleBlendMode / …
                                      ↓
                                   user screen

Key stage transitions to remember:

  • Extractor emits both skinDir/{primitive}.png and model.glb (with the same image embedded). They should match bit-for-bit.
  • manual-files overlay (copyManualFilesToFolder at packages/processor/src/index.ts:1507) silently replaces extractor output with files from packages/settings/manual-files/lol/models/{alias}/{skinId}/…. A mismatch between extractor PNG and CDN PNG almost always points here.
  • separateTexture (packages/processor/src/utils/GLTFPipelineUtils.ts) re-extracts PNGs from the current GLB after processor transforms. Runs on the main skin, not chromas.
  • Chroma path (packages/processor/src/index.ts:1603-1626) for non-chromasHaveModels skins: deletes chroma GLB, moves whatever standalone PNGs remain in the folder to chromas/{chromaId}/. No per-chroma processing — what the extractor wrote (modulo manual-files overlay) is what gets uploaded.
  • Engine (packages/web/src/engine/index.ts) applies material extras (flipbook UV, blend mode, etc.). The material's UV repeat/offset is calibrated for the embedded GLB texture dimensions. A chroma swap replaces the image but keeps the same UV transforms — if the replacement has different dimensions/frame-count, UV sampling breaks.

Source data references ​

CommunityDragon bin.json (authoritative skin/material data) ​

Riot's bin files, decoded to JSON. The source of truth for what the extractor should produce.

https://raw.communitydragon.org/pbe/game/data/characters/{champ}/skins/skin{N}.bin.json
https://raw.communitydragon.org/latest/game/data/characters/{champ}/skins/skin{N}.bin.json

Fields to look at:

  • Characters/{Champ}/Skins/Skin{N}.skinMeshProperties.materialOverride[] — which material is bound to which submesh.
  • Characters/{Champ}/Skins/Skin{N}/Materials/{MatName}_inst.samplerValues[] — each sampler has TextureName (e.g. Diffuse_Texture, FlipbookBlend_Tex, Mask_Texture) and texturePath.
  • .paramValues[] — flipbook size, tint colors, scroll speeds, etc. Pair with StaticMaterial.cs's ShaderProfilesByPath to decode which param means what.

Tip: the bin.json is large; slice with curl | python3 -c "import json,sys,json as j; d=j.loads(sys.stdin.read()); …" and walk the tree, don't grep.

In-repo references ​

  • packages/docs/cdragon-bin-json.md — bin.json structure tour.
  • packages/docs/leaguetoolkit-wiki.md — byte-level file format specs.
  • packages/docs/extractor.md — extractor flags & CLI usage.
  • packages/docs/shaders/ — shader param mappings.
  • packages/docs/common-pitfalls.md — known traps (shared texture mutation, generic Color tinting, procedural shaders, etc.). Read before touching material code.

CDN endpoints (what the viewer actually loads) ​

# Staging (no caching — changes visible immediately after R2 upload)
https://cdn.staging.modelviewer.lol/lol/models/{alias}/{skinId}/model-compressed.wasm
https://cdn.staging.modelviewer.lol/lol/models/{alias}/{skinId}/{Submesh}.png
https://cdn.staging.modelviewer.lol/lol/models/{alias}/{skinId}/chromas/{chromaId}/{Submesh}.png

# Production (Cloudflare-cached — purge required after deploy)
https://cdn.modelviewer.lol/…

Base path convention: lol/models/{alias}/{skinId}/…. Use curl -sS -o … + file {path} to get dimensions / format without tooling.

The CLI tools ​

wad-extractor (standalone C# extractor) ​

Fast path for extractor-only debugging. Skips all Node-side processing.

bash
packages/processor/binaries/wad-extractor convert-wad <Champion> \
  --skins <skinIndex> --champion-id <championId> -o /tmp/<out>
  • <skinIndex> = skinId mod 1000 (e.g. 48012 → 12). For chromas, pass the chroma's own skinIndex.
  • <championId> = numeric champion id (Trundle 48, Mordekaiser 82). Makes output folder {championId * 1000 + skinIndex}.
  • --skins 12 13 14 extracts multiple skins/chromas in one invocation.
  • --dump-materials dumps each StaticMaterial as JSON (shader name, samplers, raw params, resolved blend/tint/UV). First tool to reach for when investigating "wrong texture / wrong tint / wrong blend."
  • --live / --force-download / --alias <filter> / -s false -a false — see packages/docs/extractor.md.

Output: <out>/<Champion>/<skinFolder>/ containing model.glb + one {Submesh}.png per primitive. These PNGs are what the processor will start from.

Rebuilding the extractor ​

If you modified C# in packages/extractor/, rebuild before the binary picks up changes:

bash
cd packages/processor && pnpm run build:extractor:mac      # darwin arm64
cd packages/processor && pnpm run build:extractor:linux    # CI / deploy

Processor generate-files ​

Full pipeline (extract + Node processing + upload).

bash
# Dry run (writes to khada-temp, skips upload)
KHADA_OUTPUT_LOCATION=/tmp/khada-out pnpm run processor generate-files "[48012]"

# Remote: uploads to r2://khada-staging/ (NOT production)
pnpm run processor generate-files "[48012]" --remote

Note the root pnpm run processor already resolves the khada script in the processor package — don't add a second khada between processor and generate-files (the CLAUDE.md example shows this; it errors with unknown command 'khada').

The parameter is a JSON array of skin IDs or names: "[48012, 48013]" or "[Fright Night Trundle]". Extracts chromas automatically alongside each base skin.

Processor temp inspection ​

Mid-pipeline output lands here before upload:

$TMPDIR/khada-temp/{timestamp}/models/{alias}/{skinId}/
  model.glb                  # after standardize/dedup/injectFixes/AR
  model-lite.glb
  model-compressed.wasm
  model-lite-compressed.wasm
  model-compressed.glb.gz
  model-render.png           # OG image from generateModelRender
  {Submesh}.png              # standalone textures (these go to CDN)
  chromas/{chromaId}/
    {Submesh}.png
    model-render.png

The folder persists after the run. Use it to diff extractor output vs final upload without re-running.

Browser inspection (chrome-devtools-mcp) ​

The Astro dev server (pnpm run web) serves the viewer at http://localhost:4321/model-viewer?id=<skinId>[&tier=<chromaId>]. chrome-devtools-mcp lets you drive it.

javascript
// via mcp__…__new_page / navigate_page / evaluate_script
// model load is async — data-* attrs populate after DOMContentLoaded
async () => {
  for (let i = 0; i < 40; i++) {
    const f = window.getModelBaseFolder?.("lol", "trundle", 48012);
    if (f && !f.includes("undefined")) return { folder: f };
    await new Promise(r => setTimeout(r, 500));
  }
  return { folder: window.getModelBaseFolder?.("lol", "trundle", 48012) };
}

getModelBaseFolder(mode, alias, skinId) needs all 3 args — calling it with none returns .../undefined/models/undefined/undefined.

Get the material list ​

window.__khada_getMaterialList() posts to the mobile bridge, not a return value. Shim the bridge to capture it:

javascript
() => {
  let captured = null;
  window.ReactNativeWebView = { postMessage: (s) => { captured = JSON.parse(s); } };
  window.__khada_getMaterialList();
  return captured;  // { type: "materialList", message: [{ hash, name, visible }, ...] }
}

Inspect material → texture mapping ​

The engine doesn't expose a full debug dump by default. When you need full material state (texture source uuids, repeat/offset, userData, flipbook params), temporarily add a window hook to packages/web/src/engine/index.ts inside the constructor block that registers window.__khada_getMaterialList:

typescript
(window as any).__khada_debugChroma = () => {
  const textures = $GLTFTextures.get();
  return $GLTFMaterials.get().map((item) => {
    const m: any = item.material;
    return {
      name: item.name,
      hasMap: !!m.map,
      mapUuid: m.map?.uuid,
      sourceUuid: m.map?.source?.uuid,
      textureLookup: m.map?.source?.uuid ? textures.get(m.map.source.uuid) : null,
      repeat: m.map ? [m.map.repeat.x, m.map.repeat.y] : null,
      offset: m.map ? [m.map.offset.x, m.map.offset.y] : null,
      userData: m.userData,
    };
  });
};

Vite hot-reloads the change. Revert the hook before committing.

Key insights from this dump:

  • Same sourceUuid across multiple materials = they share the same GLB image. In the engine that means chroma swap will fetch a single PNG for all of them.
  • textureLookup populated in $GLTFTextures = the name used as the chroma filename. Mismatch between materials sharing a source means only the alphabetically-first one becomes the lookup value (first-wins at index.ts:909).

Rendering textures back to PNG (verify actual image content) ​

Three.js textures from compressed KTX2/WASM aren't directly drawImage-able. To extract what's actually bound on the GPU, render to a RenderTarget and read back:

typescript
const { WebGLRenderTarget, Scene, OrthographicCamera, Mesh, PlaneGeometry, MeshBasicMaterial }
  = await import("three");
const texClone = tex.clone();
texClone.repeat.set(1, 1); texClone.offset.set(0, 0); texClone.rotation = 0;
const rt = new WebGLRenderTarget(w, h);
const scene = new Scene();
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
const mesh = new Mesh(new PlaneGeometry(2, 2), new MeshBasicMaterial({ map: texClone }));
scene.add(mesh);
this.renderer.setRenderTarget(rt);
this.renderer.clear(); this.renderer.render(scene, camera);
const pixels = new Uint8Array(w * h * 4);
this.renderer.readRenderTargetPixels(rt, 0, 0, w, h, pixels);
// pixels is bottom-up, flip Y into a 2D canvas and toDataURL

Remember to reset repeat/offset/rotation on the clone before rendering — otherwise you see the tiled/sampled view, not the raw image.

Screenshot back to disk ​

Place images into the DOM as <img> with data URLs, then use mcp__…__take_screenshot with fullPage: true. Easier than serializing megabyte data URLs through the tool boundary.

Network capture ​

window.fetch hooks miss THREE.TextureLoader (uses new Image()). Use Resource Timing instead:

javascript
performance.getEntriesByType("resource")
  .filter(r => r.name.includes("/chromas/"))
  .map(r => ({ url: r.name, status: r.responseStatus }));

Or the mcp list_network_requests tool.

GLB inspection (Python) ​

No node required. Read the JSON chunk + BIN chunk, extract images by bufferView.

python
import struct, json
with open(glb, 'rb') as f:
    f.read(12)                                          # header: magic/version/length
    jh = f.read(8); jl, _ = struct.unpack('<I4s', jh)
    g = json.loads(f.read(jl).decode())                 # JSON chunk
    bh = f.read(8); bl, _ = struct.unpack('<I4s', bh)
    bin_data = f.read(bl)                               # BIN chunk

for i, img in enumerate(g.get('images', [])):
    bv = g['bufferViews'][img['bufferView']]
    png = bin_data[bv.get('byteOffset', 0):][:bv['byteLength']]
    if png[:8] == b'\x89PNG\r\n\x1a\n':
        w = struct.unpack('>I', png[16:20])[0]
        h = struct.unpack('>I', png[20:24])[0]
        print(f'img[{i}] {img.get("name")}: {w}x{h}, {len(png)}B')

Useful to see material → texture → image mapping:

python
for m in g['materials']:
    idx = m.get('pbrMetallicRoughness', {}).get('baseColorTexture', {}).get('index')
    print(m['name'], '->', g['textures'][idx]['source'] if idx is not None else None)

Also works on model-compressed.glb.gz — unwrap with gzip.open.

MD5 triage (the "where did it diverge" workflow) ​

When a file on the CDN doesn't match what you expect, walk it back stage by stage:

bash
md5 /tmp/extractor_out/{Champ}/{skinId}/BottomBody.png \
    $TMPDIR/khada-temp/*/models/{alias}/{skinId}/BottomBody.png \
    /tmp/cdn_copy.png

Interpretation:

  • Extractor ≠ processor temp ≠ CDN: processor stage modified it. Check copyManualFilesToFolder, separateTexture, injectModelFixes.
  • Extractor ≠ processor temp = CDN: the file got overlaid before processing. Check packages/settings/manual-files/lol/models/{alias}/{skinId}/.
  • Extractor = processor temp = CDN: pipeline is honest. The bug is upstream (extractor) or downstream (engine/UV).

Similarly for dimensions — file {path} reports PNG/WebP dims without opening an editor. Chromas occasionally ship as WebP with .png extension (content-sniffed); file shows this.

Known silent-override trap: manual-files overlay ​

Path: packages/settings/manual-files/lol/models/{alias}/{skinId}/…

These files were authored when the extractor couldn't produce correct output for a given skin. copyManualFilesToFolder('lol', …) (packages/processor/src/index.ts:1507) copies them over the extractor's output before any processing. They can be silently wrong after an extractor fix because they're not regenerated.

Signals:

  • Staging CDN file MD5 doesn't match packages/processor/binaries/wad-extractor output for the same skin.
  • File dimensions differ from the GLB's embedded texture dimensions (e.g. standalone is 512×512 but GLB has 1024×512).
  • File is WebP despite the .png extension.

Resolution: run the extractor yourself for the skin, view the extractor output, compare with the manual file. If the extractor now produces correct output, delete the manual override and re-run generate-files --remote.

bash
rm -rf packages/settings/manual-files/lol/models/{alias}/{skinId}
# also remove empty parent if it's the last skin for that alias
pnpm run processor generate-files "[<skinId>]" --remote

Git history for these files is the giveaway — they often trace back to a single early commit with no later edits.

Known trap: chroma texture UV calibration ​

The Flipbook_Frame_Select, Flipbook_Outline_blend, PanBloom, etc. materials have UV repeat/offset calibrated for the GLB's embedded texture layout (typically 2-frame horizontal flipbook, 1024×512). Chroma apply swaps material.map.source.data to the CDN PNG but keeps the UV transform.

If the chroma PNG has different dimensions or frame layout, the viewer samples the wrong region → banding / sliver / stretched content. The fix is almost always to make the PNG match the GLB layout (2-frame duplicated if the extractor does so), not to change the engine's UV code.

Known trap: shared texture source across materials ​

The engine uses material.map.source.uuid as the key in $GLTFTextures. If multiple materials share the same GLB image (common when the extractor can't disambiguate samplers), $GLTFTextures gets a first-wins entry — whichever material was processed first. All materials sharing that source pull the same chroma filename.

Post-fix of such sharing, $GLTFMaterials shows distinct sourceUuids per material. The chroma folder must have a matching PNG per distinct source. If not, users see 404s or missing swaps.

Worked example: Fright Night Trundle (48012) chroma banding ​

Condensed timeline of a debugging session — illustrates the full toolkit.

  1. Symptom (user report): "bottomBody and Weapon textures are wrong." Loaded localhost:4321/model-viewer?id=48012 and confirmed visually.
  2. Material dump via shimmed __khada_getMaterialList and a temporary __khada_debugChroma hook → all three visible materials shared the same sourceUuid.
  3. Source triage via --dump-materials:
    wad-extractor convert-wad Trundle --skins 12 --champion-id 48 -o /tmp/x --dump-materials
    Showed BottomBody's StaticMaterial listing Diffuse_Texture → Upper_Body_TX_CM.tex but FlipbookBlend_Tex → Bottom_Body_TX_CM.tex. Cross-checked against CDragon bin.json (.samplerValues[]).
  4. Root cause in extractor: both sampler names map to SamplerType.Diffuse in packages/extractor/LeagueConvert/Helpers/Samplers.cs; first-wins at Skin.cs kept the wrong one. The shader profile for Flipbook_Frame_Select already declared DiffuseTexture = "FlipbookBlend_Tex" as primary, but nothing honored it.
  5. Fix: added SamplersByName (string-keyed) alongside the enum-keyed Samplers dict; GetDiffuseTexture() now prefers the profile's named sampler. Rebuilt mac extractor.
  6. Test suite: cd packages/tests && USE_BINARY=1 pnpm test — 9/10 passed. The 10th (Vex VFX eyes) failed identically on the pre-fix binary (stash-and-rebuild confirmed); pre-existing, unrelated.
  7. Deploy: pnpm run processor generate-files "[48012]" --remote — uploaded 115 files to khada-staging.
  8. Regression: applying chroma caused banding. MD5 triage revealed the staged BottomBody.png (512×512) didn't match the extractor's output (1024×512) — traced to packages/settings/manual-files/lol/models/trundle/48012/, 18 files committed years ago as a band-aid.
  9. Resolution: deleted the manual-file override tree and re-ran generate-files --remote. CDN now serves the correct 1024×512 two-frame layout; chroma UV sampling works.

Four tool categories drove the investigation: in-browser material inspection (bug confirmation), standalone extractor + bin.json (root-cause isolation), test suite (regression gating), MD5 + dimensions triage (catching the manual-file overlay that re-broke things).

Built for engineers and AI assistants working on Khada.