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 screenKey stage transitions to remember:
- Extractor emits both
skinDir/{primitive}.pngandmodel.glb(with the same image embedded). They should match bit-for-bit. - manual-files overlay (
copyManualFilesToFolderatpackages/processor/src/index.ts:1507) silently replaces extractor output with files frompackages/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-chromasHaveModelsskins: deletes chroma GLB, moves whatever standalone PNGs remain in the folder tochromas/{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.jsonFields 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 hasTextureName(e.g.Diffuse_Texture,FlipbookBlend_Tex,Mask_Texture) andtexturePath..paramValues[]— flipbook size, tint colors, scroll speeds, etc. Pair withStaticMaterial.cs'sShaderProfilesByPathto 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.
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 (Trundle48, Mordekaiser82). Makes output folder{championId * 1000 + skinIndex}.--skins 12 13 14extracts multiple skins/chromas in one invocation.--dump-materialsdumps eachStaticMaterialas 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— seepackages/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:
cd packages/processor && pnpm run build:extractor:mac # darwin arm64
cd packages/processor && pnpm run build:extractor:linux # CI / deployProcessor generate-files
Full pipeline (extract + Node processing + upload).
# 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]" --remoteNote 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.pngThe 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.
Navigate & wait for load
// 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:
() => {
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:
(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
sourceUuidacross multiple materials = they share the same GLB image. In the engine that means chroma swap will fetch a single PNG for all of them. textureLookuppopulated 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 atindex.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:
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 toDataURLRemember 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:
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.
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:
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:
md5 /tmp/extractor_out/{Champ}/{skinId}/BottomBody.png \
$TMPDIR/khada-temp/*/models/{alias}/{skinId}/BottomBody.png \
/tmp/cdn_copy.pngInterpretation:
- 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-extractoroutput 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
.pngextension.
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.
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>]" --remoteGit 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.
- Symptom (user report): "bottomBody and Weapon textures are wrong." Loaded
localhost:4321/model-viewer?id=48012and confirmed visually. - Material dump via shimmed
__khada_getMaterialListand a temporary__khada_debugChromahook → all three visible materials shared the samesourceUuid. - Source triage via
--dump-materials:Showed BottomBody'swad-extractor convert-wad Trundle --skins 12 --champion-id 48 -o /tmp/x --dump-materialsStaticMateriallistingDiffuse_Texture → Upper_Body_TX_CM.texbutFlipbookBlend_Tex → Bottom_Body_TX_CM.tex. Cross-checked against CDragon bin.json (.samplerValues[]). - Root cause in extractor: both sampler names map to
SamplerType.Diffuseinpackages/extractor/LeagueConvert/Helpers/Samplers.cs; first-wins atSkin.cskept the wrong one. The shader profile forFlipbook_Frame_Selectalready declaredDiffuseTexture = "FlipbookBlend_Tex"as primary, but nothing honored it. - Fix: added
SamplersByName(string-keyed) alongside the enum-keyedSamplersdict;GetDiffuseTexture()now prefers the profile's named sampler. Rebuilt mac extractor. - 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. - Deploy:
pnpm run processor generate-files "[48012]" --remote— uploaded 115 files to khada-staging. - Regression: applying chroma caused banding. MD5 triage revealed the staged
BottomBody.png(512×512) didn't match the extractor's output (1024×512) — traced topackages/settings/manual-files/lol/models/trundle/48012/, 18 files committed years ago as a band-aid. - 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).