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
//! Verify every ASET row's LOD block references resolve inside the WAD's OWN block table, and that
//! each block a chain names holds that asset exactly once.
//!
//! A patch WAD carries ASET rows copied out of a source WAD. Each row packs up to four block
//! indices — `_P000` (`packed_block_ref` hi16) plus `_P001`/`_P002`/`_P003` — and those indices are
//! relative to the archive that owns the row. Copying a block out of an 11,370-block `vz.wad` into a
//! 29-block patch therefore leaves the finer LOD rungs pointing at indices that do not exist in the
//! patch. The engine reads INDX out of range, decodes whatever bytes sit there as a page index and
//! `packed_field`, and can end up sizing a streaming buffer at hundreds of gigabytes. That request
//! can never be satisfied, the node stays pending, so the pending count never drains and the
//! world-streaming gate (`FUN_004b9af0`) never releases — a spinning hang with no crash and no error.
//!
//! Every WAD that loads has zero dangling refs (measured: retail base `vz.wad` 30645 rows; our
//! WORKING dlc01 port patch 5451 rows), while the wardrobe builds that hang carry 22 each.
//! NOTE: there is no Pandemic-shipped `vz-patch.wad` — every vz-patch is a build of ours.
//!
//! Usage: `aset_refcheck <wad> [<wad> ...]` — exit code 1 if any WAD has a violation.
use mercs2_formats::aset_type_ids::type_id_for_type_hash;
use mercs2_formats::ffcs::load_ffcs_archive;
use mercs2_formats::sges::decompress_block;
use mercs2_formats::ucfx::parse_block_entry_table;
use std::collections::HashMap;
use std::fs::File;
fn main() {
let wads: Vec<String> = std::env::args().skip(1).collect();
if wads.is_empty() {
eprintln!("usage: aset_refcheck <wad> [<wad> ...]");
std::process::exit(2);
}
let mut any_bad = false;
for path in &wads {
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) => {
println!("{path}: OPEN FAILED: {e}");
any_bad = true;
continue;
}
};
let size = file.metadata().map(|m| m.len()).unwrap_or(0);
let archive = match load_ffcs_archive(&mut file, size) {
Ok(a) => a,
Err(e) => {
println!("{path}: PARSE FAILED: {e}");
any_bad = true;
continue;
}
};
let nblocks = archive.indx.len();
// rung label by position in `lod_chain()`: index 0 is always _P000, then the non-sentinel
// halves in _P001, _P002, _P003 order.
let mut violations: Vec<(usize, u32, usize, u16)> = Vec::new();
for (row, e) in archive.aset.iter().enumerate() {
for (rung, blk) in e.lod_chain().iter().enumerate() {
if (*blk as usize) >= nblocks {
violations.push((row, e.asset_hash, rung, *blk));
}
}
}
let name = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
// Every rung of a chain has to hold its asset exactly once. Rungs install coarsest first, and
// the ring consumer (`0x4b1160`) re-queues a rung that arrives before the one above it.
let mut held: HashMap<u16, HashMap<(u32, u32), usize>> = HashMap::new();
let (mut unheld, mut rungs) = (Vec::new(), 0usize);
if violations.is_empty() {
for (row, e) in archive.aset.iter().enumerate() {
let chain = e.lod_chain();
if chain.len() < 2 {
continue;
}
for (rung, blk) in chain.iter().enumerate() {
if !held.contains_key(blk) {
let dec = match decompress_block(&mut file, &archive.indx, *blk) {
Ok(d) => d,
Err(err) => {
println!("{name}: block {blk}: {err}");
std::process::exit(1);
}
};
let (_, entries) = parse_block_entry_table(&dec);
let mut counts = HashMap::new();
for ent in entries {
if let Some(type_id) = type_id_for_type_hash(ent.type_hash) {
*counts.entry((ent.name_hash, type_id)).or_insert(0usize) += 1;
}
}
held.insert(*blk, counts);
}
rungs += 1;
let count = held[blk].get(&(e.asset_hash, e.type_id)).copied().unwrap_or(0);
if count != 1 {
unheld.push((row, e.asset_hash, rung, *blk, count));
}
}
}
}
if violations.is_empty() && unheld.is_empty() {
println!(
"OK {name}: {} rows / {nblocks} blocks — all LOD refs resolve, {rungs} rungs each hold their asset once",
archive.aset.len()
);
} else if violations.is_empty() {
any_bad = true;
println!("FAIL {name}: {} of {rungs} rungs do not hold their asset exactly once", unheld.len());
for (row, hash, rung, blk, count) in unheld.iter().take(12) {
println!(" row {row}: asset 0x{hash:08X} rung[{rung}] -> block {blk} holds it {count} time(s)");
}
} else {
any_bad = true;
println!(
"FAIL {name}: {} rows / {nblocks} blocks — {} DANGLING LOD ref(s)",
archive.aset.len(),
violations.len()
);
// distinct offending block indices, with how far out of range they are
let mut seen: Vec<u16> = violations.iter().map(|v| v.3).collect();
seen.sort_unstable();
seen.dedup();
for b in &seen {
let n = violations.iter().filter(|v| v.3 == *b).count();
println!(" -> block {b} (table has {nblocks}) x{n} row(s)");
}
for (row, hash, rung, blk) in violations.iter().take(12) {
println!(" row {row}: asset 0x{hash:08X} rung[{rung}] -> block {blk}");
}
}
}
if any_bad {
std::process::exit(1);
}
}