mercs2_probe 4.0.0

Mercenaries 2 modernization — headless WAD diagnostic/export tool. Runs the carved-out diagnostics from mercs2_engine::diag as subcommands (no window).
mercs2_probe-4.0.0 is not a library.

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_probe was removed (2026-07-26). It printed drawn/total draw groups per health band, but assembled geometry from extract_container + build_indexed_all on 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_wz551 showed 3/13 groups pristine and 0/13 destroyed. Measured through the full LOD chain (Model::load, as govern_probe does) the same model gives 39/78 → 49/78 → 9/78, which is the expected shape. health_draws replaces 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 of mtrl_probe: every model container, in every block, whose MTRL cites a given texture hash; exit 1 if any block or MTRL could not be read; --unnamed-models lists every model with no known name with the named textures its own MTRL cites, --all including named models), tex_orphans (every texture chunk no MTRL of 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 from docs/data/aset_names.csv under the notes_docs_dir key of .mercs2-local.toml. Subcommands tex-rungs (every texture chunk of a big-endian archive and its PC twin, a row per block) and tex-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, each new, same or changed, with the UCFX rows and bytes a changed one 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) and rung-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 .pws files) and pws-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 their Name — the in-game identity of an unnamed model).

  • Water: watermap_probe — resolves the watr singleton through AssetSource::extract_singleton and 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 read 0 on 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::diag states these subcommands were carved out of the engine's main.rs and are render-agnostic: they read the WAD and print/emit analysis, never opening a window.
  • sm_dump carries 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_diff are written against the engine's draw gate: clause 3 indexes OBJ+0x2a0 by the SEGM record's signed node, and the LOD rung is clamped to [minLOD (M+0x80), maxLOD-1 (M+0x7c)] — header_diff exists precisely because minLOD's on-disk source was never located.
  • action_table_probe documents the container layout it verified in ucfx_byteswap::convert + action_table.rs, and targets the base-game ActionTable (type animationtable 0x207359C7, asset 0x6802C321).
  • mesh_probe cross-references docs/ucfx_tag_registry.md §3/§6; indx_dump exists to falsify a claim in accessory_bone_binding_B.md.
  • swim_clip_probe relies on state names being pandemic_hash_m2(name) — verified with m2("Upright") = 0x12C07B18, m2("Fidget") = 0x0C0A7FA6.
  • save-dump prints the .profile header fields this project pinned: character index 0x4D, upgrade index 0x4F, unlocked costumes 0x24A, 0x24B, flags 0x4C, plus the raw header[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
cargo run -p mercs2_probe -- wad-list
cargo run -p mercs2_probe -- comp-probe
cargo run -p mercs2_probe -- comp-dump HibernationControl

# a model's bone tree, with names resolved and a CSV alongside
cargo run -p mercs2_probe -- hier --model 0x39AF17DC --csv hier.csv

# pull one container / one block out to disk
cargo run -p mercs2_probe -- extract --model 0x39AF17DC model.bin
cargo run -p mercs2_probe -- extract --model 0x0BBA3066 --type 0x1CF649BB actor.bin   # pmc_hum_mattias's facefxactor
cargo run -p mercs2_probe -- dump-block 667 block667.bin

# hunt a hash / a string / a placement across every block
cargo run -p mercs2_probe -- scan-hash 0xA3CD72A7
cargo run -p mercs2_probe -- find-placement villa

# save files
cargo run -p mercs2_probe -- save-dump "C:/.../save0.profile" cash
cargo run -p mercs2_probe -- overlays "C:/.../save0.profile"

# Scaleform movies out of the WADs, laid out as the golden set expects (vz at the root)
cargo run -p mercs2_probe -- gfx-extract output/gfx_movies         --wad <game>/data/vz.wad
cargo run -p mercs2_probe -- gfx-extract output/gfx_movies/shell   --wad <game>/data/shell.wad
cargo run -p mercs2_probe -- gfx-extract output/gfx_movies/loading --wad <game>/data/Loading.wad
cargo run -p mercs2_probe -- gfx-golden output/gfx_movies \
    --md docs/reverse_engineer/gfx_golden_set.md --json docs/data/gfx_golden_set.json

# explicit WAD
cargo run -p mercs2_probe -- --wad D:/game/data/vz.wad comp-probe

The dev bins take a model name (hashed with pandemic_hash_m2) or a 0xHASH, positionally:

cargo run -p mercs2_probe --bin sm_dump        -- ch_veh_tank_ztz98
cargo run -p mercs2_probe --bin lod_chunks     -- ch_veh_tank_ztz98
cargo run -p mercs2_probe --bin node_witness   -- ch_veh_tank_ztz98
cargo run -p mercs2_probe --bin rigid_probe    -- vz_veh_tank_amx30_elite
cargo run -p mercs2_probe --bin gate_probe     -- oc_veh_helicopter_md500
cargo run -p mercs2_probe --bin charanim                             # defaults to pmc_hum_mattias_v3
cargo run -p mercs2_probe --bin aset_probe     -- 0x9FCAE910 0x89D8DE72
cargo run -p mercs2_probe --bin model_namer    -- --emit fragment.json

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

  • --wad is a main-binary flag (a few dev bins parse their own), and 42 of the src/bin/* dev bins resolve vz.wad through wad::resolve_vz_wad(None), so MERCS2_GAME_DIR / VZ_WAD override those; the rest take a WAD path argument or read game_paths' local config. Either variable takes the install root, its data folder, or the vz.wad file 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 --wad accepts the same folder-or-file forms.
  • Flag values are consumed positionally: flag_val reads the token after a flag such as --wad / --model / --index / --clip / --csv, and first_positional skips --wad, --wads, --model, --index, --clip, --csv, --skeleton-csv, --md and --json with 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 with pandemic_hash_m2 and accepted only if the hash is actually present in this model's HIER) — unresolved nodes print as ?.
  • model_namer deliberately does not ask "is this candidate's hash anywhere in ASET" — 32-bit pandemic_hash_m2 would 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_probe exists because wad::extract_container resolves 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-placement brute-scans blocks 0..6000 and stops after 96 consecutive decompress failures.
  • tex_dump writes uncompressed BMP on purpose (no extra deps) and lives in a separate binary so it isn't locked by a running game.