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).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
//! `mopp_overlay_forge` — item 3 of the MOPP in-game plan: wrap a MOPP-swapped PHY2 collision cell in
//! a `vz-patch.wad` overlay that OVERRIDES exactly that one block, last-wins.
//!
//! Three swap modes drive the 3-rung in-game ladder over the SAME resident spawn-floor cell:
//!   --mode identity    (default) re-serialize the SAME decoded MOPP bytes → block is byte-identical to
//!                       base. Proves the deploy pipeline is sound + non-breaking (rung 1).
//!   --mode return-all   swap the floor MOPP idx 0 to `encode_return_all(N)` (N = source key count):
//!                       universal-collision. Proves OUR compiler's bytecode is game-valid + correct —
//!                       the player stands only if the game ran our MOPP (rung 3, positive attribution).
//!   --mode empty        swap the floor MOPP(s) to the emit-nothing MOPP (`[0x00]`, a lone RETURN → 0
//!                       keys). Removes this cell's collision → the player FALLS THROUGH. Negative
//!                       control (rung 2, positive attribution: our block drives collision). `--all-mopps`
//!                       empties every MOPP in the chosen container (the whole floor shell).
//!
//! Pipeline (all offline-gated; base `vz.wad` is READ-ONLY and stays pristine):
//!   1. decompress the target block; walk its entry table -> model containers.
//!   2. pick the container carrying a PHY2 with an hkpMoppCode (or --model <hash>).
//!   3. extract that PHY2 body; run `swap_phy2_mopp(.., mode)` for each targeted MOPP.
//!   4. splice the swapped body back into the container — RESIZE-AWARE (descriptor size, later bodies'
//!      offsets, CSUM) — and patch the block entry table's chunk_size -> reassemble the block.
//!      GATE: rebuilt block re-walks clean (CSUM/descriptors), and for identity is byte-identical to base.
//!   5. carry EVERY ASET row that points at this block; `PatchBlock::from_decompressed` (sges +
//!      packed_field); `build_patch_wad_multi` (auto-sentinels dangling LOD rungs -> no 549GB wedge).
//!   6. `aset_refcheck` the written WAD separately as the deploy gate.
//!
//! Flags:
//!   --block <N>        the vz.wad block to override (u16; default 2612 when absent)
//!   --model <HASH>     hex container name hash (optional `0x` prefix) the PHY2 must come from; when
//!                      absent, the first container in the block carrying a PHY2+MOPP
//!   --mopp <IDX>       swap modes: the hkpMoppCode index to swap (usize; when absent, the first MOPP with
//!                      clean single-subpart [0..N-1] keys — an error if the body has none)
//!   --mopp <KIND>      `--mode authored`: `spatial` (default when absent) or `return-all`
//!   --merge-into <WAD> also merge the block into this live vz-patch.wad, written to --merge-out
//!                      (default `<out stem>-MERGED.wad` when absent)
//! A flag given without a value, or with a value that does not parse, is an error naming the flag and
//! the value; defaults apply only when the flag is absent. All flags are checked before vz.wad is read.
//!
//! Usage (`--out` is required except with `--report`):
//!   mopp_overlay_forge --block 2612 --report
//!   mopp_overlay_forge --block 2612 --mode return-all --out .../vz-patch-mopp-returnall.wad \
//!       --merge-into .../live-vz-patch.wad --merge-out .../...-MERGED.wad
//!   mopp_overlay_forge --block 2612 --mode empty --all-mopps --out .../vz-patch-mopp-empty.wad ...

use mercs2_formats::ffcs::load_ffcs_archive;
use mercs2_formats::game_paths;
use mercs2_formats::havok::{parse_phy2_body, Shape};
use mercs2_formats::model_cubeize::parse_segm;
use mercs2_formats::mopp;
use mercs2_formats::phy2_build::{build_phy2_kind, build_phy2_multi_kind, MoppKind};
use mercs2_formats::phy2_container::{phy2_span_in_container, replace_phy2_in_container};
use mercs2_formats::patch_wad::{
    build_patch_wad_multi, merge_patch_wads, AsetEntry, PatchBlock, FFCS_CERT_BLOB,
};
use mercs2_formats::phy2_moppswap::{
    count_phy2_mopps, decode_phy2_mopp_keys, swap_phy2_mopp, validate_swapped_body, SwapMode,
};
use mercs2_formats::sges::decompress_block;
use mercs2_formats::ucfx::{walk_decompressed_block, ParsedBlock};
use sha2::{Digest, Sha256};
use std::path::Path;

fn sha256_hex(b: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(b);
    h.finalize().iter().map(|x| format!("{x:02x}")).collect()
}
/// The value following flag `n`: `Ok(None)` when the flag is absent, an error when it has no value.
fn arg(a: &[String], n: &str) -> Result<Option<String>, String> {
    match a.iter().position(|x| x == n) {
        None => Ok(None),
        Some(i) => a.get(i + 1).cloned().map(Some).ok_or_else(|| format!("{n} requires a value")),
    }
}
fn has(a: &[String], n: &str) -> bool {
    a.iter().any(|x| x == n)
}
fn mode_name(m: SwapMode) -> &'static str {
    match m {
        SwapMode::Identity => "identity",
        SwapMode::ReturnAll => "return-all",
        SwapMode::Empty => "empty",
        SwapMode::Spatial => "spatial",
    }
}

/// First MOPP index whose source keys are a clean contiguous [0..N-1] run.
fn first_contiguous_mopp(body: &[u8]) -> Option<usize> {
    for i in 0..count_phy2_mopps(body) {
        if let Ok(d) = decode_phy2_mopp_keys(body, i) {
            let (ks, range, missing) = d.key_summary();
            if !ks.is_empty() && missing.is_empty() && range == Some((0, ks.len() as u32 - 1)) {
                return Some(i);
            }
        }
    }
    None
}

/// Re-author a WHOLE PHY2 body from the target container's OWN decoded collision geometry, and gate it
/// offline. Single-shape → `build_phy2`; multi-shape → `build_phy2_multi`. The original 48-byte PHY2
/// prefix (name-hash + framing fields) is preserved verbatim except byte-32 (the packfile size, which the
/// authored packfile changes) so the engine still resolves this body to the same asset. Returns the
/// authored body (prefix + authored packfile + authored engine-faithful AA/CC/EE descriptor-chain wrapper;
/// multi-shape emits the retail SHARED-pool layout: one common frame + one shared pool, global indices, all
/// N EEs → that pool — see `build_multi_mesh_wrapper` / `build_shared_mesh_set`).
fn author_whole_phy2(
    container: &[u8],
    base_body: &[u8],
    ename: u32,
    mopp_kind: MoppKind,
) -> Result<Vec<u8>, String> {
    // 1. Decode the source meshes (in packfile order) → model-local tris/verts.
    let src = parse_phy2_body(base_body).map_err(|e| format!("decode source PHY2: {e}"))?;
    let meshes: Vec<(Vec<[u32; 3]>, Vec<[f32; 3]>)> = src
        .shapes
        .iter()
        .filter_map(|s| match s {
            Shape::Mesh(m) if !m.indices.is_empty() => Some((
                m.indices.iter().map(|t| [t[0] as u32, t[1] as u32, t[2] as u32]).collect::<Vec<_>>(),
                m.vertices.clone(),
            )),
            _ => None,
        })
        .collect();
    if meshes.is_empty() {
        return Err("source PHY2 has no decodable WpMeshShape16".into());
    }
    let n_shapes = meshes.len();
    let segm = parse_segm(container);
    println!(
        "  AUTHORED: {} decodable mesh(es); container SEGM records = {}",
        n_shapes,
        segm.len()
    );
    for (i, (t, v)) in meshes.iter().enumerate() {
        println!("    mesh[{i}]: {} tris, {} verts", t.len(), v.len());
    }
    if !segm.is_empty() && segm.len() != n_shapes {
        return Err(format!(
            "shape count {n_shapes} != SEGM record count {} — authored shapes MUST match SEGM count",
            segm.len()
        ));
    }
    if n_shapes > 1 {
        println!(
            "  MULTI-SHAPE authoring (retail SHARED pool): one common quantization frame + ONE shared vertex \
             pool that every sub-mesh indexes with GLOBAL indices, all N leaf EEs → that one pool (EE+76 → \
             wrapper+1232), MERGED 2N−1 BV-tree ((N−1) internal AA nodes w/ CC union-AABB + two children, N \
             leaf AA nodes → EE). Reproduces the retail floor's frame + record/CC/EE/pool topology; tree is \
             our own proven-edge caterpillar."
        );
    }
    if mopp_kind == MoppKind::ReturnAll {
        println!(
            "  MOPP = RETURN-ALL (diagnostic): each shape's spatial MOPP replaced by encode_return_all(ntris) \
             — SAME m_info + mesh + wrapper, ONLY the m_data bytecode changes. STAND ⇒ spatial frame is the \
             culprit; FALL ⇒ upstream m_info/AABB/mesh/binding."
        );
    }

    // 2. Author the whole PHY2.
    let name = format!("authored_collider_0x{ename:08X}");
    let authored = if n_shapes == 1 {
        build_phy2_kind(&name, &meshes[0].0, &meshes[0].1, mopp_kind)?
    } else {
        build_phy2_multi_kind(&name, &meshes, mopp_kind)?
    };

    // 3. Preserve the original 48-byte prefix (name-hash + framing) except byte-32 = packfile size.
    if base_body.len() < 48 || authored.len() < 48 {
        return Err("PHY2 body shorter than the 48-byte prefix".into());
    }
    let mut out = Vec::with_capacity(authored.len());
    out.extend_from_slice(&base_body[0..48]); // original prefix verbatim
    out[32..36].copy_from_slice(&authored[32..36]); // …but the authored packfile size
    out.extend_from_slice(&authored[48..]); // authored packfile + wrapper

    // 4. Offline gate: re-parse the authored body.
    let pf = parse_phy2_body(&out).map_err(|e| format!("re-parse authored PHY2: {e}"))?;
    for class in ["WpMeshShape16", "hkpMoppBvTreeShape", "hkpMoppCode", "WpArray"] {
        let want = if class == "WpArray" { 1 } else { n_shapes as u32 };
        let got = pf.class_counts.get(class).copied().unwrap_or(0);
        if got != want {
            return Err(format!("authored gate: class {class} count {got} != {want}"));
        }
    }
    // Each mesh decodes back to its OWN triangle indices (deterministic — from the packfile).
    let re_meshes: Vec<&mercs2_formats::havok::MeshShape> = pf
        .shapes
        .iter()
        .filter_map(|s| match s {
            Shape::Mesh(m) if !m.indices.is_empty() => Some(m),
            _ => None,
        })
        .collect();
    if re_meshes.len() != n_shapes {
        return Err(format!("authored gate: {} meshes decoded, want {n_shapes}", re_meshes.len()));
    }
    // For a multi-shape (shared-pool) body the packfile index arrays are GLOBAL indices, so validate GEOMETRY
    // (triangle vertex POSITIONS, order-preserving) rather than raw index equality. Single-shape stays local,
    // so raw index equality still holds there.
    if n_shapes == 1 {
        let want: Vec<[u16; 3]> =
            meshes[0].0.iter().map(|t| [t[0] as u16, t[1] as u16, t[2] as u16]).collect();
        if re_meshes[0].indices != want {
            return Err("authored gate: single-shape mesh indices did not round-trip".into());
        }
    } else {
        for (i, (tris, verts)) in meshes.iter().enumerate() {
            if re_meshes[i].indices.len() != tris.len() {
                return Err(format!("authored gate: mesh[{i}] triangle count changed"));
            }
            for (t, gt) in tris.iter().enumerate() {
                for k in 0..3 {
                    let got = re_meshes[i].vertices[re_meshes[i].indices[t][k] as usize];
                    let want = verts[gt[k] as usize];
                    let d = ((got[0] - want[0]).powi(2) + (got[1] - want[1]).powi(2) + (got[2] - want[2]).powi(2)).sqrt();
                    if d > 0.1 {
                        return Err(format!("authored gate: mesh[{i}] tri {t} corner {k} position err {d} > 0.1"));
                    }
                }
            }
        }
    }
    // For single-shape, verticies must round-trip within quant error (the reader locks the one pool).
    if n_shapes == 1 {
        let re = re_meshes[0];
        let (tris, verts) = &meshes[0];
        let maxidx = *tris.iter().flat_map(|t| t.iter()).max().unwrap() as usize;
        let mut worst = 0.0f32;
        for i in 0..=maxidx {
            let (a, b) = (re.vertices[i], verts[i]);
            let d = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
            worst = worst.max(d);
        }
        if worst > 0.1 {
            return Err(format!("authored gate: single-shape vertex round-trip error {worst} > 0.1"));
        }
        println!("    single-shape vertex round-trip: worst dequant error {worst:.5} m (< 0.1)");

        // ★ ENGINE-FAITHFUL WRAPPER GATE: walk the trailing AA/CC/EE descriptor chain exactly as the
        // retail loader relocates it (`&body[0]+offset`) and prove every pool/descriptor pointer resolves.
        // This is the property the old pool-first wrapper lacked → in-game AV @0x0248C15A.
        let mesh = mercs2_formats::phy2_build::write_wpmesh16(&meshes[0].0, &meshes[0].1)?;
        let packfile_size = u32::from_le_bytes(out[32..36].try_into().unwrap()) as usize;
        let pkend = 48 + packfile_size;
        mercs2_formats::phy2_build::validate_wrapper_chain(&out, pkend, &mesh)
            .map_err(|e| format!("authored gate: faithful wrapper chain FAILED: {e}"))?;
        println!("    faithful wrapper chain: AA→CC→AA→EE→pool all relocate in-bounds (engine-walkable)");
    } else {
        // ★ MERGED BV-TREE ENGINE-FAITHFUL WRAPPER GATE: walk the WHOLE 2N−1 tree from the root (every
        // internal two-child + every leaf EE/pool/tail) exactly as the loader relocates it, and prove each
        // leaf's EE+76 pool pointer addresses ITS OWN pool whose dequantized verts reproduce that mesh's
        // input (deterministic, not the tolerant content scan). This is the property the old N-linked-chains
        // wrapper lacked — the full-tree walk is the gate that was missing.
        // Build the SHARED-POOL layout from the same meshes (the exact structure the builder emits) and walk
        // the merged tree, asserting every leaf's EE+76 points to the ONE shared pool.
        let sms = mercs2_formats::phy2_build::build_shared_mesh_set(&meshes)
            .map_err(|e| format!("authored gate: shared-mesh-set: {e}"))?;
        let per_mesh_ntris: Vec<u32> = sms.subs.iter().map(|s| s.tris_global.len() as u32).collect();
        let packfile_size = u32::from_le_bytes(out[32..36].try_into().unwrap()) as usize;
        let pkend = 48 + packfile_size;
        mercs2_formats::phy2_build::validate_multi_wrapper_chain(&out, pkend, &sms.pool, &per_mesh_ntris)
            .map_err(|e| format!("authored gate: merged BV-tree walk FAILED: {e}"))?;
        // Per-mesh vertex correctness via the ONE shared pool (leaf l's EE at ee_region + l*108). Dequantize
        // each sub-mesh's GLOBAL triangle indices under the common frame and compare to the input positions.
        let ic = if n_shapes <= 1 { 1 } else { n_shapes - 1 };
        let ee_region = (ic + n_shapes) * 68 + ic * 108;
        let shared_pool =
            u32::from_le_bytes(out[pkend + ee_region + 76..pkend + ee_region + 80].try_into().unwrap()) as usize;
        let mut worst = 0.0f32;
        for (i, (tris, verts)) in meshes.iter().enumerate() {
            let ee = pkend + ee_region + i * 108;
            let poolptr = u32::from_le_bytes(out[ee + 76..ee + 76 + 4].try_into().unwrap()) as usize;
            if poolptr != shared_pool {
                return Err(format!("authored gate: mesh[{i}] EE+76 pool {poolptr} != shared pool {shared_pool}"));
            }
            let deqg = |g: usize| -> [f32; 3] {
                let o = shared_pool + g * 6;
                [
                    sms.min[0] + u16::from_le_bytes([out[o], out[o + 1]]) as f32 * sms.scale[0],
                    sms.min[1] + u16::from_le_bytes([out[o + 2], out[o + 3]]) as f32 * sms.scale[1],
                    sms.min[2] + u16::from_le_bytes([out[o + 4], out[o + 5]]) as f32 * sms.scale[2],
                ]
            };
            for (t, gt) in sms.subs[i].tris_global.iter().enumerate() {
                for k in 0..3 {
                    let got = deqg(gt[k] as usize);
                    let want = verts[tris[t][k] as usize];
                    let d = ((got[0] - want[0]).powi(2) + (got[1] - want[1]).powi(2) + (got[2] - want[2]).powi(2)).sqrt();
                    worst = worst.max(d);
                }
            }
        }
        if worst > 0.1 {
            return Err(format!("authored gate: multi-shape per-mesh vertex round-trip error {worst} > 0.1"));
        }
        println!(
            "    merged BV-tree walk (SHARED pool): {n_shapes} leaves reached from root, all EE+76 → the one \
             shared pool @body[{shared_pool}]; per-mesh global-index dequant worst {worst:.5} m (< 0.1)"
        );
        // ★ DISTINCT-SHAPE BINDING GATE: the packfile global-fixup graph must bind N DISTINCT shapes
        // (WpArray → N distinct bvtrees → N distinct moppcodes + N distinct meshes), never collapsed onto
        // shape[0]. This is the invariant the live A/B x32dbg proof flagged and that the mesh/MOPP census
        // (virtual-fixup enumeration) is structurally blind to.
        mercs2_formats::phy2_build::validate_multi_shape_binding(&out, n_shapes)
            .map_err(|e| format!("authored gate: distinct-shape binding FAILED: {e}"))?;
        println!("    distinct-shape binding: WpArray → {n_shapes} distinct bvtrees → {n_shapes} distinct moppcodes + meshes (record[i]→shape[i])");
    }
    // Each MOPP decodes clean to [0..tris_i).
    let re_mopps = mopp::extract_mopp_with_info(&out);
    if re_mopps.len() != n_shapes {
        return Err(format!("authored gate: {} MOPPs, want {n_shapes}", re_mopps.len()));
    }
    for (i, (code, _)) in re_mopps.iter().enumerate() {
        let d = mopp::decode(code);
        if d.error.is_some() || d.consumed != code.len() {
            return Err(format!("authored gate: MOPP[{i}] decode {:?}, coverage {}/{}", d.error, d.consumed, code.len()));
        }
        let (ks, range, missing) = d.key_summary();
        let want_n = meshes[i].0.len();
        if ks.len() != want_n || range != Some((0, want_n as u32 - 1)) || !missing.is_empty() {
            return Err(format!("authored gate: MOPP[{i}] keys {} range {range:?} (want [0..{want_n}))", ks.len()));
        }
    }
    println!(
        "  AUTHORED offline gate PASS: {n_shapes} shape(s), class census OK, indices + MOPP keys round-trip"
    );
    Ok(out)
}

/// One MOPP's key summary for the report / gate lines.
fn mopp_line(body: &[u8], idx: usize) -> String {
    match decode_phy2_mopp_keys(body, idx) {
        Ok(d) => {
            let (ks, range, missing) = d.key_summary();
            let tag = if !ks.is_empty() && missing.is_empty() && range == Some((0, ks.len() as u32 - 1))
            {
                "single-subpart [0..N-1]"
            } else if missing.is_empty() {
                "contiguous (offset base)"
            } else {
                "multi-subpart / gaps"
            };
            format!(
                "{} keys, range {:?}, {} missing, err {:?}  [{tag}]",
                ks.len(),
                range,
                missing.len(),
                d.error
            )
        }
        Err(e) => format!("decode failed: {e}"),
    }
}

fn print_report(parsed: &ParsedBlock, block: u16) {
    println!("=== mopp_overlay_forge REPORT — block {block} MOPP inventory ===");
    let mut any = false;
    for (i, c) in parsed.containers.iter().enumerate() {
        let Some((s, sz)) = phy2_span_in_container(c) else {
            continue;
        };
        let body = &c[s..s + sz];
        let n = count_phy2_mopps(body);
        if n == 0 {
            continue;
        }
        any = true;
        let nh = parsed.entries[i].name_hash;
        println!(
            "container[{i}] name 0x{nh:08X}  container {} B  PHY2 {sz} B @ +0x{s:X}  {n} MOPP(s):",
            c.len()
        );
        for m in 0..n {
            println!("    mopp[{m}]: {}", mopp_line(body, m));
        }
    }
    if !any {
        println!("(no container with a PHY2+MOPP in this block)");
    }
}

fn main() {
    if let Err(e) = run() {
        eprintln!("mopp_overlay_forge: error: {e}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), String> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    // Every flag value is validated here, before vz.wad is opened.
    let block: u16 = match arg(&args, "--block")? {
        None => 2612,
        Some(s) => s
            .parse()
            .map_err(|e| format!("--block {s:?} is not a valid vz.wad block index (u16): {e}"))?,
    };
    let model_hash: Option<u32> = match arg(&args, "--model")? {
        None => None,
        Some(s) => Some(
            u32::from_str_radix(s.trim_start_matches("0x"), 16)
                .map_err(|e| format!("--model {s:?} is not a valid hex container name hash: {e}"))?,
        ),
    };
    let report = has(&args, "--report");
    // Every run except `--report` writes the overlay WAD named by `--out`.
    const OUT_REQUIRED: &str = "--out <file.wad> is required (the standalone overlay WAD to write)";
    let out = arg(&args, "--out")?;
    if out.is_none() && !report {
        return Err(OUT_REQUIRED.into());
    }
    let merge_into = arg(&args, "--merge-into")?;
    let merge_out = arg(&args, "--merge-out")?;
    if merge_out.is_some() && merge_into.is_none() {
        return Err("--merge-out names the merged WAD of --merge-into; pass --merge-into <WAD> with it".into());
    }
    let all_mopps = has(&args, "--all-mopps");
    let mode_arg = arg(&args, "--mode")?;
    let mopp_arg = arg(&args, "--mopp")?;
    // `authored` is a whole-PHY2 re-authoring mode (not a MOPP swap): decode the target container's
    // mesh(es), re-author a fully-authored PHY2 (WpMeshShape16 + engine-faithful descriptor-chain wrapper
    // + native MOPP) from that SAME geometry, and splice it in. Single-shape uses build_phy2 (proven); a
    // multi-shape container uses build_phy2_multi (see the wrapper caveat printed at run time).
    let authored = matches!(mode_arg.as_deref(), Some("authored"));
    // On the authored path, `--mopp` selects which MOPP bytecode each shape carries: `spatial` (default,
    // the real collision) or `return-all` (the frame-ignoring diagnostic — SAME mesh/wrapper/m_info, only
    // the bytecode changes; see phy2_build::MoppKind). On the swap paths it is the MOPP index to swap.
    let (mopp_kind, mopp_index): (MoppKind, Option<usize>) = if authored {
        let kind = match mopp_arg.as_deref() {
            None | Some("spatial") => MoppKind::Spatial,
            Some("return-all") | Some("returnall") => MoppKind::ReturnAll,
            Some(other) => return Err(format!("unknown --mopp {other} (spatial|return-all)")),
        };
        (kind, None)
    } else {
        let idx = match mopp_arg {
            None => None,
            Some(s) => Some(
                s.parse::<usize>()
                    .map_err(|e| format!("--mopp {s:?} is not a valid hkpMoppCode index (usize): {e}"))?,
            ),
        };
        (MoppKind::Spatial, idx)
    };
    let mode = if authored {
        SwapMode::Identity // placeholder; unused on the authored path
    } else {
        match mode_arg.as_deref() {
            None | Some("identity") => SwapMode::Identity,
            Some("return-all") | Some("returnall") => SwapMode::ReturnAll,
            Some("empty") => SwapMode::Empty,
            Some(other) => {
                return Err(format!("unknown --mode {other} (identity|return-all|empty|authored)"))
            }
        }
    };
    let vz = game_paths::vz_wad(Path::new("."))
        .ok_or("vz.wad not found — set MERCS2_GAME_DIR or .mercs2-local.toml")?;
    let mut f = std::fs::File::open(&vz).map_err(|e| format!("open {vz:?}: {e}"))?;
    let size = f.metadata().map_err(|e| e.to_string())?.len();
    let ar = load_ffcs_archive(&mut f, size).map_err(|e| format!("ffcs: {e}"))?;

    let path = ar
        .paths
        .get(block as usize)
        .cloned()
        .ok_or_else(|| format!("block {block} has no PTHS path"))?;

    // 1. decompress + walk
    let dec = decompress_block(&mut f, &ar.indx, block).map_err(|e| format!("decompress {block}: {e}"))?;
    let dec_sha = sha256_hex(&dec);
    let (parsed, issues) = walk_decompressed_block(&dec, "target");
    for is in &issues {
        eprintln!("  base walk issue: {} :: {}", is.context, is.detail);
    }

    if report {
        println!("base wad : {}", vz.display());
        println!("target   : block {block}  '{path}'");
        println!("decompressed block: {} B  sha256 {dec_sha}", dec.len());
        print_report(&parsed, block);
        return Ok(());
    }
    let out = out.ok_or(OUT_REQUIRED)?;

    let authored_label = match mopp_kind {
        MoppKind::ReturnAll => "authored/return-all",
        MoppKind::Spatial => "authored",
    };
    println!("=== mopp_overlay_forge (mode={}) ===", if authored { authored_label } else { mode_name(mode) });
    println!("base wad : {}", vz.display());
    println!("target   : block {block}  '{path}'");
    println!("decompressed block: {} B  sha256 {dec_sha}", dec.len());

    // 2. pick the container carrying a PHY2+MOPP (optionally constrained to --model hash)
    let mut chosen: Option<usize> = None;
    for (i, c) in parsed.containers.iter().enumerate() {
        if let Some(mh) = model_hash {
            if parsed.entries[i].name_hash != mh {
                continue;
            }
        }
        if let Some((s, sz)) = phy2_span_in_container(c) {
            if count_phy2_mopps(&c[s..s + sz]) > 0 {
                chosen = Some(i);
                break;
            }
        }
    }
    let ci = chosen.ok_or("no container with a PHY2+MOPP found in this block")?;
    let ename = parsed.entries[ci].name_hash;
    let (pstart, psize) = phy2_span_in_container(&parsed.containers[ci]).unwrap();
    let base_body = parsed.containers[ci][pstart..pstart + psize].to_vec();
    let n_mopps = count_phy2_mopps(&base_body);
    println!(
        "container[{ci}] name 0x{ename:08X}: PHY2 body {psize} B @ container+0x{pstart:X}, {n_mopps} MOPP(s)",
        );
    println!("  src PHY2 body sha256 {}", sha256_hex(&base_body));

    let new_body = if authored {
        author_whole_phy2(&parsed.containers[ci], &base_body, ename, mopp_kind)?
    } else {
        // Which MOPP indices does this mode target?
        let targets: Vec<usize> = match mode {
            SwapMode::Empty if all_mopps => (0..n_mopps).collect(),
            _ => match mopp_index {
                Some(i) => vec![i],
                None => vec![first_contiguous_mopp(&base_body).ok_or_else(|| {
                    format!(
                        "none of container 0x{ename:08X}'s {n_mopps} MOPP(s) has clean single-subpart \
                         [0..N-1] keys; pass --mopp <IDX> to choose one"
                    )
                })?],
            },
        };
        println!("  targeting MOPP index(es): {targets:?}");

        // Apply the swap(s) sequentially. Identity & empty are size-preserving (in-place), so successive
        // MOPP indices stay valid across calls; return-all is applied to a single index.
        let mut body = base_body.clone();
        for &idx in &targets {
            if idx >= count_phy2_mopps(&body) {
                return Err(format!("mopp index {idx} out of range ({n_mopps} MOPPs)"));
            }
            let src_keys = decode_phy2_mopp_keys(&body, idx)?.keys;
            let (nb, rep) = swap_phy2_mopp(&body, idx, mode)?;
            let expected: Option<&[u32]> = if mode == SwapMode::Identity { Some(&src_keys) } else { None };
            let g = validate_swapped_body(&nb, idx, mode, expected);
            let gate_ok = g.reparse_ok
                && g.mopp_present
                && g.decode_clean
                && g.decode_coverage_full
                && g.keys_as_expected
                && g.mesh_still_decodes;
            println!(
                "  mopp[{idx}]: {} src keys -> {} decoded key(s), buf {}->{} B, packfile {}->{} B ({}), offline-gate {}",
                rep.source_key_count,
                g.decoded_key_count,
                rep.old_buf_len,
                rep.new_buf_len,
                rep.old_packfile_size,
                rep.new_packfile_size,
                if rep.grew { "GREW/appended" } else { "in-place" },
                if gate_ok { "PASS" } else { "FAIL" }
            );
            if !gate_ok {
                return Err(format!("mopp[{idx}] offline gate FAILED: {g:?}"));
            }
            if mode == SwapMode::Identity && nb != body {
                return Err("identity swap was not byte-identical".into());
            }
            body = nb;
        }
        body
    };
    println!("  new PHY2 body: {} B  sha256 {}", new_body.len(), sha256_hex(&new_body));

    // 4. splice the swapped body back into the container (RESIZE-AWARE) + rebuild the block.
    let new_container = replace_phy2_in_container(&parsed.containers[ci], pstart, psize, &new_body)?;
    let header_end = 4 + parsed.entry_count as usize * 16;
    let mut rebuilt = Vec::with_capacity(dec.len() + new_container.len());
    rebuilt.extend_from_slice(&dec[0..header_end]); // count + entry table
    // patch this container's entry-table chunk_size to the (possibly resized) container length.
    let cs_off = 4 + ci * 16 + 12;
    rebuilt[cs_off..cs_off + 4].copy_from_slice(&(new_container.len() as u32).to_le_bytes());
    for (i, c) in parsed.containers.iter().enumerate() {
        if i == ci {
            rebuilt.extend_from_slice(&new_container);
        } else {
            rebuilt.extend_from_slice(c);
        }
    }
    let rebuilt_sha = sha256_hex(&rebuilt);
    println!("rebuilt block: {} B  sha256 {rebuilt_sha}", rebuilt.len());

    // GATE A: rebuilt block re-walks clean (CSUM + descriptor bounds) — the resize-correctness proof.
    let (_rp, ri) = walk_decompressed_block(&rebuilt, "rebuilt");
    if !ri.is_empty() {
        for is in &ri {
            eprintln!("  rebuilt walk issue: {} :: {}", is.context, is.detail);
        }
        return Err("rebuilt block failed the re-walk (CSUM/descriptor) gate".into());
    }
    println!("  RE-WALK GATE: rebuilt block walks clean (CSUM + descriptors OK)");
    // GATE B (identity only): byte-identical to base.
    if mode == SwapMode::Identity && !authored {
        println!(
            "  IDENTITY GATE — rebuilt == base decompressed: {}",
            if rebuilt == dec { "YES (byte-identical)" } else { "NO !!" }
        );
        if rebuilt != dec {
            return Err("identity rebuilt block is NOT byte-identical to base".into());
        }
    }

    // 5. carry EVERY ASET row that points at this block; build the overlay
    let aset: Vec<AsetEntry> = ar
        .aset
        .iter()
        .filter(|e| e.block_index() == block)
        .map(|e| AsetEntry::new(e.asset_hash, e.secondary_ref, e.packed_block_ref, e.type_id))
        .collect();
    let tier = ar.indx.get(block as usize).map(|i| i.packed_field);
    println!(
        "carrying {} ASET row(s) for block {block}; inherit tier {:?}",
        aset.len(),
        tier.map(|t| t >> 24)
    );
    let pblk = PatchBlock::from_decompressed(&rebuilt, path.clone(), aset, tier)?;
    println!(
        "patch block: {} declared pages, sges {} B",
        pblk.declared_pages(),
        pblk.compressed_data.len()
    );

    if let Some(parent) = Path::new(&out).parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
    }
    let wad = build_patch_wad_multi(std::slice::from_ref(&pblk), 0, None, &FFCS_CERT_BLOB)?;
    std::fs::write(&out, &wad).map_err(|e| format!("write {out}: {e}"))?;
    println!("\nWROTE standalone overlay: {out}");
    println!("  {} B  sha256 {}", wad.len(), sha256_hex(&wad));

    // Optional: also emit a variant that MERGES this block into a live vz-patch.wad (append; the live
    // overlay is preserved, our block added, last-wins for c33294).
    if let Some(live) = merge_into {
        let existing = std::fs::read(&live).map_err(|e| format!("read live {live}: {e}"))?;
        let merged = merge_patch_wads(&existing, vec![pblk], false)?;
        let mout = merge_out.unwrap_or_else(|| format!("{}-MERGED.wad", out.trim_end_matches(".wad")));
        std::fs::write(&mout, &merged).map_err(|e| format!("write {mout}: {e}"))?;
        println!("\nWROTE merged overlay (live + our block): {mout}");
        println!("  live in : {} ({} B)", live, existing.len());
        println!("  {} B  sha256 {}", merged.len(), sha256_hex(&merged));
    }

    println!("\nMount as data/vz-patch.wad (overlay, last-wins). Gate next: aset_refcheck <wad>");
    Ok(())
}