mercs2_probe
Headless WAD diagnostic and export tool for the Mercenaries 2 modernization — it runs the
diagnostics carved out of mercs2_engine::diag as subcommands, and never opens a window.
What it is
A binary-only crate. It ships two kinds of executable:
1. mercs2_probe itself (src/main.rs) — one subcommand per diagnostic. Most delegate straight
to a mercs2_engine::diag::* entry point; a handful are implemented in main.rs itself. The full
set, exactly as matched in main():
| subcommand | what it does |
|---|---|
c3-meta <out.ndjson> |
diag::c3_meta |
placement-hashes <out.json> |
diag::placement_hashes |
export-c3-obj <outdir> |
diag::export_c3_obj |
terrainmesh-probe [block] |
diag::terrainmesh_probe |
terrain-probe / terrain-consumer |
diag::terrain_probe / diag::terrain_consumer_scan |
wad-list |
diag::wad_list |
wad-meshes [--model H] |
diag::wad_meshes |
placement-probe / placement-names |
diag::placement_probe / diag::placement_names |
world-index / stream-probe |
diag::world_index_probe / diag::stream_probe |
animdiag / animcheck / skincheck [--model H] [--index I] |
the animation diagnostics |
trackmap [--model H] [--index I] [--clip H] |
diag::trackmap |
entity-find [0xKEY ...] |
diag::entity_find |
comp-probe / comp-dump [Name] |
reflection-component census / dump (comp-dump defaults to HibernationControl) |
block-grep <needle> |
diag::block_grep |
scan-hash <0xH ...> / find-ref <0xH ...> |
diag::scan_hash / diag::find_ref |
block-probe <index> |
diag::block_probe |
veg-census <out.json> |
diag::veg_census — understory coverage; writes the vegetation tag-set JSON to out.json |
tree-census <out.json> |
diag::tree_census — c3-cell canopy trees; writes the per-instance list JSON to out.json |
gfx-extract <outdir> |
diag::gfx_extract — writes every cfx_pack movie of --wad (the container's data body, verbatim) |
gfx-golden <movies_dir> --md <report.md> --json <index.json> |
inventories extracted movies into the GFx golden reference set; reads files, needs no WAD |
extract --model 0xHASH [--type 0xTYPEHASH] [out.bin] |
wad::extract_container → file; with --type, wad::extract_container_typed reads the chunk of that CHDR class (a character's model and its facefxactor can share one name) |
facefx <name|0xH> / facefx --all |
a FaceFX actor (face bones, reference pose beside the model's HIER local, poses) and anim set (anims, curves with their value range) by name; --all reads every FaceFX container of every mounted WAD, prints the census (node classes, link functions, curve names), how many takes name a voice clip and an english string, and each set's speaker as the shipped scripts bind it (mercs2_engine::facecast), and exits 1 if any container fails |
dump-block <index> [out.bin] |
wad::decompress_block_index → file |
find-placement <name-substring> |
scans blocks 0..6000, prints every placement whose name matches |
hier --model 0xHASH [names.txt] [--csv path] |
HIER bone tree with name resolution (see below) |
overlays <profile-path> |
resolves a save's active vz_state layers and folds them through the real streaming catalog |
save-dump <profile-path> [grep-needle] |
parsed .profile header + the decompressed SaveSingleton Lua payload |
anim-table <name|0xH> [--handle 0xH] [--list <column>] |
an animationtable as the loader holds it: columns, defaults row, data rows, and each row's ASTO list |
layer <name|0xH> [--overlay <wad>] / layer --all / layer --frames <capture> / layer --catalog <out.tsv> [--also <archives>] |
every entity of a placement layer with its template, model and component classes, and the lineregion hash a LineRegion names; --overlay mounts a WAD over the stack, e.g. a level's patch over vz.wad; --all reads every layer of the stack, prints how many entities draw a model, how placed PhysicalLink records resolve and which classes the rest carry, and exits 1 if a layer does not read; --frames compares every placed entity a v1.0 capture holds with its live SceneObject position: a link child against its chain of links, the rest against their Transform; --catalog writes one TSV row per layer of the stack with its archive, block path, entity, model and link counts and class counts, plus a bare row per layer of each --also archive, of any platform |
clip-names <review.tsv> |
names the AnimationLookup's clips from the rows that list them (player_mattias_rifle_upright_run_forward_fb): the row's character and equipment, then a stance and action from words the data supplies. Writes a review file, each name with its expected chance matches, whether its stance agrees with the stance tables, and how many clips share its stance and action; merges nothing. Reads rainbow_table, jul08_strings and notes_docs_dir from .mercs2-local.toml |
we-template <name|0xKEY>... · --class <Class> · --uses <0xU32> · --enums |
the retail worldentity: a template's component records, a class's schema (field names) and records, every record holding a value, or the enum tables |
clip-pose --model H --clip H --bones 0xA,0xB.. --ref 0xR [--frames N] |
a clip posed on a model as the engine poses it: the bones' model-space positions per frame and their positions in the reference bone's frame |
dump-read <capture-dir> <0xVA> [len] |
retail memory from an x32dbg capture (game-files/memory-dumps/<build>/<state>); no WAD |
dump-chain <capture-dir> <0xVA> <0xOFF>... |
follows [[VA]+o0]+o1… (the last offset is added, not read) |
dump-find <capture-dir> <0xU32>... |
every 4-aligned captured address holding one of the values |
dump-find-vec3 <capture> <x> <y> <z> [--tol T] |
every 4-aligned address where three consecutive f32 lie within T (default 0.0005) |
dump-containers <capture-dir> |
all 334 live component containers: name, class, type id, live count, stride |
dump-pool <capture-dir> <name|0xVA> [--key 0xK] [--bytes N] [--limit N] |
one container's live records: key, address, bytes |
dump-hibernation <capture> |
the hibernation grid (0x0179C7B0): viewers, every row (via each SceneObject's block index), awake count; its retail test checks mercs2_core::hibernation against every row |
dump-spawnlists <capture> |
the runtime unit lists the game built from PopulationList data (table 0x016E51F0): units and weights in stored order |
<capture> is either a capture folder (<build>/<state>/ with process.dmp, SHA256SUMS.txt,
capture.log) or a <yyyymmdd>_<label>.dmp listed in a sibling MANIFEST.md. The dump-* commands
refuse a dump whose bytes do not match the recorded sha256, a folder capture whose exe (capture.log
exe_sha256=) is not a v1.0 build in mercs2_exemap's builds.tsv, and any dump whose own image
fails the three-address v1.0 code-byte test (pc_exe_build_fingerprints.md) — only v1.0 shares the
Ghidra decomp's addresses. An address the capture did not hold is an error, never
zeros. Their tests are game-gated (--features retail) and read memory_dumps_dir from
.mercs2-local.toml.
2. ~100 single-purpose dev bins under src/bin/ — each is a standalone investigation, run with
cargo run -p mercs2_probe --bin <name>. They cluster into:
-
Model assembly / LOD:
lod_chunks,lodchain_probe,model_blocks,segm_join,assembly_probe,assembly_check,overlap_probe,stray_probe,viewstate_probe,header_diff,rigid_probe. -
Node binding (which HIER node places which mesh):
indx_dump,segfix_probe,segm_probe2,node_witness,place_probe,hier_dump,hier_chain. -
Destruction / draw gate:
sm_dump,gate_probe,govern_probe,health_draws(drawn/total draw groups at full / half / zero health through the full LOD chain),mesh_probe.health_probewas removed (2026-07-26). It printed drawn/total draw groups per health band, but assembled geometry fromextract_container+build_indexed_allon the resident block alone — which for a vehicle is the far-LOD proxy (game_world.rs: "renders 371-triangle tanks"). Its counts described the coarse stand-in, not what the engine draws, and read as a destruction bug:ch_veh_apc_wz551showed 3/13 groups pristine and 0/13 destroyed. Measured through the full LOD chain (Model::load, asgovern_probedoes) the same model gives 39/78 → 49/78 → 9/78, which is the expected shape.health_drawsreplaces it, through the LOD chain. -
Animation:
action_table_probe,charanim,clipbind,swim_clip_probe. -
Textures / materials / FX / lights:
tex_dump,texcmp,mtrl_probe,fx_probe,light_probe,tex_users(the reverse ofmtrl_probe: every model container, in every block, whoseMTRLcites a given texture hash; exit 1 if any block or MTRL could not be read;--unnamed-modelslists every model with no known name with the named textures its ownMTRLcites,--allincluding named models),tex_orphans(every texture chunk noMTRLof any loader cites — model, terrainmesh, font, lowresterrain, scrub — grouped by block, with any other chunk that holds the hash as a raw u32). Both read names fromdocs/data/aset_names.csvunder thenotes_docs_dirkey of.mercs2-local.toml. Subcommandstex-rungs(every texture chunk of a big-endian archive and its PC twin, a row per block) andtex-compare(an Xbox archive's textures, converted and decoded mip by mip against PC, or a PS3 archive with--ps3). -
DLC: subcommand
dlc-census(every chunk of a big-endian DLC archive against the retail build of its platform, eachnew,sameorchanged, with the UCFX rows and bytes achangedone differs in),clip-compare(every clip of a converted WAD decoded against PC),pc-diff(every chunk of a converted WAD against its PC twin, row by row),model-compare(every model of a big-endian archive converted against PC over its whole rung chain, triangles, list B and skin),scrub-compare(the same for ground cover, its kept rows, triangles and translations),terrain-compare(two WADs' terrain as wound triangles),phy2-diff(one collider converted, its differences by region) andrung-words(a PC WAD's chunk table words against the rule for them). -
Audio: subcommands
stfs-list(the files of an Xbox package),pws-survey(every wavebank record of a console package or big-endian WAD, each streamed one against the bytes it addresses;--extract <dir>writes the.pwsfiles) andpws-pc(every streamed record of a PC WAD with the first bytes of its range). -
Archive / naming:
aset_probe(every ASET row a name-hash owns + a multi-block striping census),model_namer(derives a model's name from the textures it uses, and can emit a rainbow- table name fragment),kit_templates(the resident worldentity's spawn templates that wear a given model, by theirName— the in-game identity of an unnamed model). -
Water:
watermap_probe— resolves thewatrsingleton throughAssetSource::extract_singletonand reports grid, world extent, wet/dry census, waterline range and the Layer 0 × Layer 1 cross-tab. The cross-tab is the regression guard on the 44-byte header: a wet cell carrying the dry sentinel (or vice versa) means the height field has slipped against the mask, which on retail is silent and costs 4,681 coastline cells. It must read0on both misalignment rows.
Where it comes from
vz.wad is the oracle. Every probe reads the retail archive and reports what is actually on disk —
nothing here reimplements exe code, it interrogates the data the exe consumes, via
mercs2_engine::{wad, mesh, worldutil, diag} and mercs2_formats::{orchestrator, placement, save, hash}.
Provenance the source itself records:
mercs2_engine::diagstates these subcommands were carved out of the engine'smain.rsand are render-agnostic: they read the WAD and print/emit analysis, never opening a window.sm_dumpcarries the cracked destruction state vocabulary (InitState 0x0ACE072A,PristineState 0xACB51200,DamagedState 0x1D5575A1,InitDestroyedState 0x5D308F4F,DestroyedState 0x7687DF41,StartDestroyedState 0x92791EBB,GoneState 0xCA261E5B,DamageMsg 0xC6507EE1,DestroyMsg 0x1ED7AD78,DestroyMsg2 0x3D0D4C99).gate_probe/viewstate_probe/header_diffare written against the engine's draw gate: clause 3 indexesOBJ+0x2a0by the SEGM record's signednode, and the LOD rung is clamped to[minLOD (M+0x80), maxLOD-1 (M+0x7c)]—header_diffexists precisely becauseminLOD's on-disk source was never located.action_table_probedocuments the container layout it verified inucfx_byteswap::convert+action_table.rs, and targets the base-game ActionTable (typeanimationtable 0x207359C7, asset0x6802C321).mesh_probecross-referencesdocs/ucfx_tag_registry.md§3/§6;indx_dumpexists to falsify a claim inaccessory_bone_binding_B.md.swim_clip_proberelies on state names beingpandemic_hash_m2(name)— verified withm2("Upright") = 0x12C07B18,m2("Fidget") = 0x0C0A7FA6.save-dumpprints the.profileheader fields this project pinned: character index0x4D, upgrade index0x4F, unlocked costumes0x24A,0x24B, flags0x4C, plus the rawheader[0x240..0x260]window and the zlib-decompressed Lua payload.
Usage
vz.wad auto-discovers from MERCS2_GAME_DIR / VZ_WAD, then the EA Games registry key; override
it with --wad <path>:
HKLM\SOFTWARE\WOW6432Node\EA Games\Mercenaries 2 World in Flames\Install Dir
# archive census
# a model's bone tree, with names resolved and a CSV alongside
# pull one container / one block out to disk
# hunt a hash / a string / a placement across every block
# save files
# Scaleform movies out of the WADs, laid out as the golden set expects (vz at the root)
# explicit WAD
The dev bins take a model name (hashed with pandemic_hash_m2) or a 0xHASH, positionally:
mercs2_probe with no subcommand prints the usage summary and exits 2. -h / --help / help do
the same once a vz.wad resolves; with none, they print the "no vz.wad found" message and exit 1.
A failing diagnostic exits 1.
Notes / gotchas
--wadis a main-binary flag (a few dev bins parse their own), and 42 of thesrc/bin/*dev bins resolvevz.wadthroughwad::resolve_vz_wad(None), soMERCS2_GAME_DIR/VZ_WADoverride those; the rest take a WAD path argument or readgame_paths' local config. Either variable takes the install root, itsdatafolder, or thevz.wadfile itself. The dev bins previously went straight to the EA Games registry key and panicked without it, which made them unrunnable off Windows and after a copied-off install. The main binary's--wadaccepts the same folder-or-file forms.- Flag values are consumed positionally:
flag_valreads the token after a flag such as--wad/--model/--index/--clip/--csv, andfirst_positionalskips--wad,--wads,--model,--index,--clip,--csv,--skeleton-csv,--mdand--jsonwith their values (any other--token is skipped alone). A flag value that itself starts with--is rejected. hier's name resolution is rainbow-table first, then an optional plain-text candidate file (one string per line, hashed withpandemic_hash_m2and accepted only if the hash is actually present in this model's HIER) — unresolved nodes print as?.model_namerdeliberately does not ask "is this candidate's hash anywhere in ASET" — 32-bitpandemic_hash_m2would fabricate names. It only asks whether a candidate generated from this model's own materials hashes to this model's hash (~50-1000 candidates against one target).aset_probeexists becausewad::extract_containerresolves one ASET row (primary, else any) and slices one span out of one block — if an asset's rows span several blocks, that call returns a fragment, not the asset.find-placementbrute-scans blocks0..6000and stops after 96 consecutive decompress failures.tex_dumpwrites uncompressed BMP on purpose (no extra deps) and lives in a separate binary so it isn't locked by a running game.