Drag-and-drop .cast merger that doesn't break your materials.
A thin wrapper around Scobalula/ModelMerger (as extended with Cast support by echo000/ModelMerger) that automatically repairs a material/texture bug introduced when merging .cast models, so the merged output imports into Unreal Engine with working textures and auto-shading — no manual re-import workaround needed.
When merging two Call of Duty .cast models (e.g. a body + a head) with ModelMerger.exe, the resulting merged .cast file loads fine geometrically (bones, meshes, skeleton are all correctly combined), but every material's textures are broken. Importing the merged file into Unreal Engine via o-Astral-o/UECast produces materials with no textures assigned — auto-shading falls back to default/placeholder maps (black diffuse, flat normal, etc.) on every single material, regardless of which source model it came from.
The usual workaround was to re-import the original (unmerged) body and head .cast files separately afterward, just to let their materials "override" the broken ones and restore textures. That works, but it defeats half the point of merging in the first place, and it's an annoying manual step every time.
Traced by disassembling the .NET IL of PhilLibX.dll and ModelMerger.exe, and cross-referencing against the echo000/ModelMerger source (which added .cast support on top of the original SEModel-only tool):
-
LoadCastModel()inProgram.csonly copies the material name:foreach (var material in Model.Materials()) { var mat = new Model.Material(material.Name()); model.Materials.Add(mat); }
It never reads the material's texture slots (
albedo,normal,extra0,extra1, etc.) or resolves them to actual file paths.Model.Material's internalImagesdictionary — the thingPhilLibX.dll's export code (Model.ProcessTexture) reads from when writing the merged.castback out — is left completely empty. -
At export time,
ProcessTexturecomputes a texture hash from whatever path it finds (or doesn't find) inImages:string path = material.GetImage("DiffuseMap"); // empty, since Images was never populated ulong hash = FNV1a.Calculate64(path, FNV1a.OffsetBasis64);
FNV1a64("")is mathematically just the offset basis itself, unchanged:14695981039346656037. Since every material hits this same empty-string case, every texture slot on every material ends up pointing at the exact same hash, with an empty path — confirmed by parsing the merged.cast's binaryfilenodes directly and finding this exact value repeated everywherep(path) was blank.
This is a load-side bug, not a merge-logic bug — the actual material-list merging (if not already present, add) is fine. It's specifically the Cast material loader that never got wired up to populate texture data, unlike whatever loads SEModel-side data (which never had it either, incidentally — same root issue, just never surfaced there).
fix_merged_cast.py (built on a small custom .cast binary reader/writer, cast_parser.py, since no Python library exists for the format):
- Parses the original, unmerged source
.castfiles (body, head, ...) and builds a map ofmaterial name → {texture slot: real file path}, resolved directly from each source file's own validfilenode hash → path table. - Parses the merged (broken)
.castoutput fromModelMerger.exe. - For each material in the merged file, matches it back to its original by name, drops the broken texture properties/hash, and rebuilds correct
filenodes with fresh, deterministic hashes (FNV1a64of the real path) and the actual texture path. - Important structural detail:
filenodes must be re-attached as children of their owningmatlnode (matching the original Cast convention used by every other exporter) — not as children of the parentmodlnode. Attaching them in the wrong place produces a.castthat still parses fine and still has "correct" hash values, but is invisible to importers that only traversefilenodes scoped under each material, which is exactly what caused a first attempt at this fix to still fail silently in Unreal (materials with correct hashes, still zero textures imported). - Writes out a repaired
.castfile, ready to import directly.
automerge.py + AutoMerge.bat (or a compiled AutoMerge.exe via PyInstaller) chain the whole thing into a single drag-and-drop step:
- Drop your source
.castfiles (body, head, ...) ontoAutoMerge.bat. - It runs
ModelMerger.exeon them (auto-answering its "press Enter to exit" prompt so it doesn't hang waiting for input). - It auto-detects whichever
.castfileModelMerger.exejust produced. - It runs the texture repair from step above, using your original dropped files as the source of truth.
- Out comes
<name>_MERGED_FIXED.cast, ready to import into Unreal with working materials and auto-shading — no separate re-import step required.
ModelMerger.exe's location is auto-detected on first run (checked next to the tool, in the parent folder, and in Documents/Downloads) and cached in automerge_config.ini, so nothing needs to be hardcoded or recompiled if your setup differs.
AutoMerge.bat body.cast head.cast
(or just drag both files onto it)
First run only: if ModelMerger.exe can't be found automatically, you'll be asked once for its path.
pip install pyinstaller
python -m PyInstaller --onefile --console --icon=AutoMerge.ico --name AutoMerge automerge.py
- Materials are matched between the merged file and the source files by exact name. If a source model's material name changes between files, it won't be matched and will be left as-is (a warning is printed listing anything that couldn't be repaired).
- This only repairs material/texture references. It doesn't re-validate or alter meshes, bones, or skeleton data — if
ModelMerger.exehas other bugs unrelated to textures, this tool won't catch them. - The
extra0/extra1→ gloss/specular slot convention is inferred from observed CoD exporter output, not from a formal spec; verify it matches your exporter if you rely on those specific slots.
- Scobalula — original
ModelMergerandPhilLibX. - echo000 — fork of
ModelMergeradding.castsupport. - o-Astral-o —
UECast, the Unreal Engine.castimporter and auto-shading plugin this tool's output is designed to work with.