use mercs2_core::hibernation::{GridEntry, HibernationScale, Quantized, Viewer, MAX_VIEWERS};
use crate::dump::{Capture, Mem};
const GRID: u32 = 0x0179_C7B0;
const VIEWER_POS: u32 = 0x0179_C800;
const BLOCK_BASE: u32 = 0x0117_5DD8;
const BLOCK_SIZE: u32 = 0x1F0;
const NOT_IN_GRID: u16 = 0x0FFF;
pub struct LiveGrid {
pub scale: HibernationScale,
pub viewers: [Option<Viewer>; MAX_VIEWERS],
pub rows: Vec<GridEntry>,
}
fn f32_at(mem: &Mem, va: u32) -> Result<f32, String> {
mem.u32(va).map(f32::from_bits)
}
pub fn live_grid(mem: &Mem) -> Result<LiveGrid, String> {
let scale = HibernationScale {
k: f32_at(mem, GRID + 0x38)?,
inv_k: f32_at(mem, 0x00D1_BF34)?,
radius_factor: f32_at(mem, 0x0117_5DC8)?,
min_distance: f32_at(mem, 0x0117_5DC4)?,
};
let margin = f32_at(mem, GRID + 0x3C)?;
if margin != mercs2_core::hibernation::MARGIN {
return Err(format!("grid margin {margin}, expected {}", mercs2_core::hibernation::MARGIN));
}
let count = mem.u16(GRID + 0xA)? as i16;
let mut viewers = [None; MAX_VIEWERS];
for i in 0..count.max(0) as u32 {
let v = mem.u8(GRID + 0x44 + i)? as usize;
if v >= MAX_VIEWERS {
return Err(format!("viewer id {v} out of range"));
}
let p = VIEWER_POS + 12 * v as u32;
viewers[v] = Some(Viewer {
position: [f32_at(mem, p)?, f32_at(mem, p + 4)?, f32_at(mem, p + 8)?],
local: mem.u8(GRID + 0x40 + v as u32)? != 0,
});
}
let so = mem.container_named("SceneObject")?;
let base = mem.u32(BLOCK_BASE)?;
let mut blocks = std::collections::BTreeSet::new();
for r in mem.records(&so)? {
let idx = mem.u16(r.va + 0x18)? & 0x0FFF;
if idx != NOT_IN_GRID {
blocks.insert(idx);
}
}
let mut rows = Vec::new();
for idx in blocks {
let b = base + u32::from(idx) * BLOCK_SIZE;
let blk = mem.read(b, BLOCK_SIZE as usize)?;
let rd16 = |o: usize| u16::from_le_bytes([blk[o], blk[o + 1]]);
let n = rd16(0x1E4) as i16;
let cell = i32::from(rd16(0x1E6) as i16);
let statics = u32::from_le_bytes([blk[0x1E0], blk[0x1E1], blk[0x1E2], blk[0x1E3]]);
for i in 0..n.max(0) as usize {
let packed = rd16(0xA0 + 4 * i + 2);
rows.push(GridEntry {
key: u32::from_le_bytes([blk[4 * i], blk[4 * i + 1], blk[4 * i + 2], blk[4 * i + 3]]),
q: Quantized { cell, bx: blk[0xA0 + 4 * i], bz: blk[0xA1 + 4 * i], ylo: blk[0x120 + i], yhi: (packed >> 14) as u8 },
r14: packed & 0x3FFF,
rings: [blk[0x140 + 3 * i], blk[0x141 + 3 * i], blk[0x142 + 3 * i]],
base: rd16(0x1A0 + 2 * i),
is_static: statics >> i & 1 != 0,
state: blk[0x80 + i],
});
}
}
Ok(LiveGrid { scale, viewers, rows })
}
pub fn dump_hibernation(capture: &str) -> Result<(), String> {
let cap = Capture::open(std::path::Path::new(capture))?;
let mem = cap.mem()?;
let g = live_grid(&mem)?;
println!("[dump-hibernation] {} (build {}): {} grid rows, scale {:?}", cap.path.display(), cap.build_id, g.rows.len(), g.scale);
for (v, vw) in g.viewers.iter().enumerate() {
if let Some(vw) = vw {
println!("viewer {v}: ({:.3}, {:.3}, {:.3}) local {}", vw.position[0], vw.position[1], vw.position[2], vw.local);
}
}
let awake = g.rows.iter().filter(|r| r.state & 0x0F != 0).count();
println!("awake (mask set): {awake}");
Ok(())
}
#[cfg(all(test, feature = "retail"))]
mod tests {
use super::*;
use mercs2_core::hibernation::{evaluate, quantize, HibernationScale, DEFAULT_DISTANCE};
use std::path::Path;
#[test]
fn the_core_reproduces_every_captured_grid_row() {
let start = Path::new(env!("CARGO_MANIFEST_DIR"));
let dir = mercs2_formats::game_paths::local_config_memory_dumps_dir(start)
.unwrap_or_else(|e| panic!("{e}"))
.join("updated-chris-exports");
let mut total = 0;
for name in [
"20261021_chris-jacobs-flying-winch.dmp",
"20261021_chris-jacobs-mid-mission.dmp",
"20261021_clean-load-chris-jacobs-7-percent.dmp",
"20261021_mattias-nilson-jacket-skin.dmp",
] {
let cap = Capture::open(&dir.join(name)).unwrap_or_else(|e| panic!("{e}"));
let mem = cap.mem().unwrap_or_else(|e| panic!("{e}"));
let g = live_grid(&mem).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(g.scale, HibernationScale::single_player(), "{name}: single-player scale");
assert!(g.rows.len() > 70_000, "{name}: {} rows", g.rows.len());
let so = mem.container_named("SceneObject").unwrap();
let so_va: std::collections::HashMap<u32, u32> = mem.records(&so).unwrap().into_iter().map(|r| (r.key, r.va)).collect();
let hc = mem.container_named("HibernationControl").unwrap();
let hc_va: std::collections::HashMap<u32, u32> = mem.records(&hc).unwrap().into_iter().map(|r| (r.key, r.va)).collect();
let rso = mem.container_named("RuntimeSceneObject").unwrap();
let node: std::collections::HashSet<u32> = mem
.records(&rso)
.unwrap()
.into_iter()
.filter(|r| mem.u32(r.va + 0x34).unwrap() != 0)
.map(|r| r.key)
.collect();
let (mut quantized, mut with_node, mut runtime_moved, mut unvisited_spawns) = (0, 0, 0, 0);
let mut placed_misses = Vec::new();
for row in &g.rows {
if row.state & 0x0F != 0 && row.state & 0xF0 == 0 && g.viewers.iter().flatten().all(|v| v.local) {
assert!(row.key >= 0x1000_0000, "{name}: placed key 0x{:08X} in a spawn-only state {row:?}", row.key);
unvisited_spawns += 1;
} else {
assert_eq!(evaluate(row, &g.viewers, &g.scale), row.state, "{name}: key 0x{:08X} {row:?}", row.key);
}
let so = so_va[&row.key];
let hc = hc_va.get(&row.key).or_else(|| hc_va.get(&mem.u32(so + 0x14).unwrap()));
let dist0 = hc.map_or(DEFAULT_DISTANCE, |&a| mem.u16(a).unwrap());
assert_eq!(row.r14, g.scale.radius(dist0, row.is_static), "{name}: key 0x{:08X} radius of distance {dist0}", row.key);
if node.contains(&row.key) {
with_node += 1;
continue;
}
let pos = [f32_at(&mem, so).unwrap(), f32_at(&mem, so + 4).unwrap(), f32_at(&mem, so + 8).unwrap()];
if quantize(pos) == row.q {
quantized += 1;
} else if row.key >= 0x1000_0000 {
runtime_moved += 1;
} else {
placed_misses.push((row.key, pos, row.q));
}
}
eprintln!(
"{name}: {} rows, {quantized} re-quantised, {with_node} with a render node, {runtime_moved} runtime moved, {unvisited_spawns} spawns not yet visited",
g.rows.len()
);
assert!(unvisited_spawns <= 2, "{name}: {unvisited_spawns} spawn-only rows");
let y_only = placed_misses.iter().filter(|(_, p, q)| {
let mine = quantize(*p);
(mine.cell, mine.bx, mine.bz) == (q.cell, q.bx, q.bz)
}).count();
let ever_awake = placed_misses.iter().filter(|(k, _, _)| g.rows.iter().any(|r| r.key == *k && r.state != 0)).count();
eprintln!("{name}: {} placed misses, {y_only} only in y, {ever_awake} currently masked/ringed", placed_misses.len());
let base_of: std::collections::HashMap<u32, u16> = g.rows.iter().map(|r| (r.key, r.base)).collect();
let no_node: Vec<u32> = placed_misses.iter().filter(|(k, _, _)| base_of[k] == 0).map(|(k, _, _)| *k).collect();
eprintln!("{name}: placed misses with base 0 (no render node at encode): {}", no_node.len());
if name.contains("clean-load") || name.contains("mattias") {
assert!(no_node.is_empty(), "{name}: placed entities off their row with no render node: {no_node:08X?}");
}
total += g.rows.len();
}
assert!(total > 280_000);
}
#[test]
fn live_node_enables_follow_the_activation_rule() {
let start = Path::new(env!("CARGO_MANIFEST_DIR"));
let wad_path = mercs2_formats::game_paths::local_config_vz_wad(start).unwrap_or_else(|e| panic!("{e}"));
let mut w = mercs2_engine::wad::open(&wad_path.to_string_lossy()).unwrap_or_else(|e| panic!("{e}"));
let mut tables: std::collections::HashMap<u32, Vec<bool>> = std::collections::HashMap::new();
let dir = mercs2_formats::game_paths::local_config_memory_dumps_dir(start)
.unwrap_or_else(|e| panic!("{e}"))
.join("updated-chris-exports");
for name in [
"20261021_chris-jacobs-flying-winch.dmp",
"20261021_chris-jacobs-mid-mission.dmp",
"20261021_clean-load-chris-jacobs-7-percent.dmp",
"20261021_mattias-nilson-jacket-skin.dmp",
] {
let cap = Capture::open(&dir.join(name)).unwrap_or_else(|e| panic!("{e}"));
let mem = cap.mem().unwrap_or_else(|e| panic!("{e}"));
let rso = mem.container_named("RuntimeSceneObject").unwrap();
let (mut ok, mut bad) = (0, Vec::new());
for r in mem.records(&rso).unwrap().into_iter().filter(|r| r.key < 0x1000_0000) {
let node = mem.u32(r.va + 0x34).unwrap();
if node == 0 || mem.u32(node).unwrap() != 0x00BA_B0F0 {
continue;
}
let model_res = mem.u32(node + 0x1E0).unwrap();
let (model, n) = (mem.u32(model_res + 4).unwrap(), mem.u32(model_res + 0x5C).unwrap() as usize);
let table = match tables.get(&model) {
Some(t) => t.clone(),
None => {
let c = mercs2_engine::wad::extract_container(&mut w, model).unwrap_or_else(|e| panic!("{e}"));
let t = mercs2_formats::render_node::activation_enable(&c).unwrap_or_else(|e| panic!("model 0x{model:08X}: {e}"));
tables.insert(model, t.clone());
t
}
};
let live: Vec<bool> = mem.read(mem.u32(node + 0x2A0).unwrap(), n).unwrap().iter().map(|&b| b != 0).collect();
if live == table {
ok += 1;
} else {
bad.push((r.key, model));
}
}
eprintln!("{name}: {ok} placed posed nodes match the activation enable table, {} do not: {:08X?}", bad.len(), &bad[..bad.len().min(12)]);
if name.contains("clean-load") || name.contains("mattias") {
assert!(bad.is_empty(), "{name}: live enable tables off the activation rule: {bad:08X?}");
}
}
}
#[test]
fn placed_rows_follow_the_shipped_records() {
use mercs2_engine::worldutil::{NodeSource, NodeSpheres, PlacedCatalog, TemplateRecords};
use std::collections::{BTreeMap, HashMap};
let start = Path::new(env!("CARGO_MANIFEST_DIR"));
let wad_path = mercs2_formats::game_paths::local_config_vz_wad(start).unwrap_or_else(|e| panic!("{e}"));
let mut assets = mercs2_engine::asset::AssetSource::discover(&wad_path.to_string_lossy(), &[]).unwrap_or_else(|e| panic!("{e}"));
let templates = TemplateRecords::load(&mut assets).unwrap_or_else(|e| panic!("{e}"));
let mut w = mercs2_engine::wad::open(&wad_path.to_string_lossy()).unwrap_or_else(|e| panic!("{e}"));
let mut catalog = PlacedCatalog::default();
let (mut blocks, mut named, mut name_only) = (0, 0, Vec::new());
for b in 0..=mercs2_engine::worldutil::MAX_PLACEMENT_BLOCK {
let dec = mercs2_engine::wad::decompress_block_index(&mut w, b).unwrap_or_else(|e| panic!("block {b}: {e}"));
if catalog.add_block(&dec, &templates).unwrap_or_else(|e| panic!("block {b}: {e}")).is_empty() {
continue;
}
blocks += 1;
for p in mercs2_formats::placement::load_placements(&dec).unwrap_or_else(|e| panic!("block {b}: {e}")) {
let Some(n) = p.name else { continue };
named += 1;
let h = mercs2_formats::hash::pandemic_hash_m2(n.trim_start_matches('_'));
if catalog.get(p.key).is_some_and(|o| o.model.is_none()) && mercs2_engine::wad::has_model(&w, h) {
name_only.push((p.key, n));
}
}
}
eprintln!("{blocks} placement blocks, {} placed entities; {named} named, {} whose name hashes to a model the records do not give", catalog.len(), name_only.len());
assert!(name_only.is_empty(), "names resolving a model the records do not give: {:?}", &name_only[..name_only.len().min(20)]);
let mut spheres = NodeSpheres::default();
let dir = mercs2_formats::game_paths::local_config_memory_dumps_dir(start)
.unwrap_or_else(|e| panic!("{e}"))
.join("updated-chris-exports");
for name in [
"20261021_chris-jacobs-flying-winch.dmp",
"20261021_chris-jacobs-mid-mission.dmp",
"20261021_clean-load-chris-jacobs-7-percent.dmp",
"20261021_mattias-nilson-jacket-skin.dmp",
] {
let cap = Capture::open(&dir.join(name)).unwrap_or_else(|e| panic!("{e}"));
let mem = cap.mem().unwrap_or_else(|e| panic!("{e}"));
let g = live_grid(&mem).unwrap_or_else(|e| panic!("{name}: {e}"));
let hc = mem.container_named("HibernationControl").unwrap();
let live_hc: HashMap<u32, u32> = mem.records(&hc).unwrap().into_iter().map(|r| (r.key, r.va)).collect();
let mut node_rows: BTreeMap<String, (usize, Vec<u32>)> = BTreeMap::new();
let (mut rows, mut terrain_unactivated, mut runtime_hc) = (0, 0, 0);
for row in g.rows.iter().filter(|r| r.key < 0x1000_0000) {
rows += 1;
let o = *catalog.get(row.key).unwrap_or_else(|| panic!("{name}: placed key 0x{:08X} is in no placement block", row.key));
let reg = o.registration();
assert!(
row.is_static == reg.is_static && row.r14 == g.scale.radius(reg.distance, reg.is_static) && row.rings == reg.rings,
"{name}: key 0x{:08X} row {row:?} vs registration {reg:?}",
row.key
);
if let Some(&va) = live_hc.get(&row.key) {
let live = (mem.u16(va).unwrap(), [mem.u8(va + 2).unwrap(), mem.u8(va + 3).unwrap(), mem.u8(va + 4).unwrap()], mem.u8(va + 5).unwrap() & 3);
match o.own_hibernation {
Some(h) => assert_eq!(
(h.distance, h.rings(), u8::from(h.flags) | u8::from(h.no_del_on_hibernate_if_preplaced) << 1),
live,
"{name}: key 0x{:08X} live HibernationControl differs from its shipped record",
row.key
),
None => runtime_hc += 1,
}
}
if row.base == 10_000 {
assert!(o.terrain_mesh.is_some() && quantize(reg.position) == row.q, "{name}: key 0x{:08X} base 10000 {row:?} {o:?}", row.key);
terrain_unactivated += 1;
continue;
}
if row.base == 0 {
continue;
}
let kind = match o.node {
NodeSource::None => "none".to_string(),
NodeSource::Model { tiny_geometry: true, .. } => "tinygeometry".to_string(),
NodeSource::Model { tiny_geometry: false, .. } => "posed model".to_string(),
NodeSource::LowResTile(_) => "low-res tile".to_string(),
NodeSource::Light(l, _) => format!("light type {}", l.light_type),
};
let node = spheres.sphere(&mut w, o.node).unwrap_or_else(|e| panic!("{name}: key 0x{:08X}: {e}", row.key));
let r = o.activated_registration(node);
let e = node_rows.entry(kind).or_default();
if node.is_some() && quantize(r.position) == row.q && r.base == row.base {
e.0 += 1;
} else {
e.1.push(row.key);
}
}
eprintln!("{name}: {rows} placed rows, {terrain_unactivated} TerrainObjects never activated, {runtime_hc} live HibernationControl records on placed keys that ship none");
for (kind, (ok, bad)) in &node_rows {
eprintln!("{name}: {kind}: {ok} re-celled rows reproduced, {} not: {:08X?}", bad.len(), &bad[..bad.len().min(12)]);
}
let missed: usize = node_rows.values().map(|(_, bad)| bad.len()).sum();
if name.contains("clean-load") || name.contains("mattias") {
assert_eq!(missed, 0, "{name}: re-celled rows the catalog does not reproduce");
}
}
}
}