mercs2_probe 2.2.2

Mercenaries 2 modernization — headless WAD diagnostic/export tool. Runs the carved-out diagnostics from mercs2_engine::diag as subcommands (no window).
mercs2_probe-2.2.2 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
gfx-extract [outdir] diag::gfx_extract — dumps the Scaleform movies (default output/gfx_movies)
extract --model 0xHASH [out.bin] wad::extract_container → file
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

2. ~34 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, seedcmp, 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. seedcmp replaces it and additionally compares both NodeSeed variants. If a per-health probe is wanted again, bring it back under a name that says what it measures — and load 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.

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

  • 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 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 -- 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 WAD
cargo run -p mercs2_probe -- gfx-extract

# 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 (or -h / --help / help) prints the usage summary and exits 2; a failing diagnostic exits 1.

Notes / gotchas

  • --wad is a main-binary flag only, but every src/bin/* dev bin now resolves vz.wad through wad::resolve_vz_wad(None), so VZ_WAD overrides them all. VZ_WAD 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 --wad / --model / --index / --clip / --csv, and first_positional skips exactly those five flags and their values. 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.