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. 4801212). 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.