Compare commits
6
Commits
4d1e277a15
...
aa2b14ad66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2b14ad66 | ||
|
|
c83f5104d2 | ||
|
|
bbdb51e2f9 | ||
|
|
69a8398f6e | ||
|
|
2c7555fe16 | ||
|
|
ccea53807a |
@@ -132,6 +132,7 @@ Read these before working in an unfamiliar area:
|
||||
| `docs/plan/` | One file per subsystem, written **as work proceeds** |
|
||||
| `docs/formats/` | Format specs, written **as formats are reversed** |
|
||||
| `PROVENANCE.md` | Upstream projects we build on, and their licences |
|
||||
| `docs/plan/pcsx2-verification.md` | PCSX2 is set up on this machine, and how to run the comparison loop |
|
||||
| `vendor/reRAC/` | Pinned reference checkout (not tracked) |
|
||||
|
||||
## Crates
|
||||
|
||||
Generated
+11
@@ -2360,6 +2360,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-trace"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
"gc-engine",
|
||||
"gc-formats",
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-video"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/gc-engine",
|
||||
"crates/gc-game",
|
||||
"crates/gc-video",
|
||||
"tools/trace",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -23,8 +24,16 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
gc-formats = { path = "crates/gc-formats" }
|
||||
gc-extract = { path = "crates/gc-extract" }
|
||||
gc-engine = { path = "crates/gc-engine" }
|
||||
gc-game = { path = "crates/gc-game" }
|
||||
gc-video = { path = "crates/gc-video" }
|
||||
gc-trace = { path = "tools/trace" }
|
||||
|
||||
# Dev tools. PCSX2's savestates are zip archives whose entries are
|
||||
# zstd- or deflate-compressed; both backends are pure Rust and are already
|
||||
# in the lockfile through Bevy, so the trace tool adds no new sources.
|
||||
ruzstd = "0.8"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
|
||||
@@ -88,6 +88,12 @@ Visual and behavioural comparison against PCSX2, replaying recorded input and
|
||||
comparing frames, level by level. The PS2 float model is reproduced rather than
|
||||
approximated — see [`AGENTS.md`](AGENTS.md#2-reproduce-the-ps2-float-model).
|
||||
|
||||
PCSX2 is installed and working on this machine; the comparison loop that reads
|
||||
the game's own memory over its debug protocol is up, and level 0's and level
|
||||
1's tfrag lighting are verified byte-exact against it. See
|
||||
[`docs/plan/pcsx2-verification.md`](docs/plan/pcsx2-verification.md) for the
|
||||
setup and how to run it.
|
||||
|
||||
## Credits and licensing
|
||||
|
||||
This project learns from and is built on the work of others. Licences are
|
||||
|
||||
@@ -136,6 +136,44 @@ fn active_set(bank: &LightBank, select: u16) -> Option<ActiveSet> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The pass's terms for one vertex.
|
||||
///
|
||||
/// This exists because a colour mismatch on its own cannot be attributed: the
|
||||
/// final byte is a sum of five things, and the PCSX2 comparison -- the only
|
||||
/// check on this pass there is -- is worthless without knowing *which* of them
|
||||
/// the game disagrees with. It is a snapshot of what the pass computed, not a
|
||||
/// second implementation: `light_vertex_n` and this share one body.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VertexTerms {
|
||||
/// The record's azimuth and elevation indices.
|
||||
pub azimuth: u8,
|
||||
pub elevation: u8,
|
||||
/// The record's base colour, PEXT5-expanded.
|
||||
pub base: [u8; 4],
|
||||
/// The `select` word, the set it named, and whether it blended two.
|
||||
pub select: u16,
|
||||
pub set_index: Option<usize>,
|
||||
pub blended: bool,
|
||||
/// The vertex's normal.
|
||||
pub normal: [f32; 4],
|
||||
/// The active set's colours and directions, with the w lanes cleared as
|
||||
/// the pass clears them.
|
||||
pub color_a: [f32; 4],
|
||||
pub color_b: [f32; 4],
|
||||
pub dir_a: [f32; 4],
|
||||
pub dir_b: [f32; 4],
|
||||
/// The back-face factors taken from the colour w lanes.
|
||||
pub wa: f32,
|
||||
pub wb: f32,
|
||||
/// The two dot products, and each after its `max(d, d*w)`.
|
||||
pub dot_a: f32,
|
||||
pub dot_b: f32,
|
||||
pub max_a: f32,
|
||||
pub max_b: f32,
|
||||
/// The final colour.
|
||||
pub out: [u8; 4],
|
||||
}
|
||||
|
||||
/// Light one vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`.
|
||||
pub fn light_vertex(
|
||||
bank: &LightBank,
|
||||
@@ -151,20 +189,65 @@ fn light_vertex_n(
|
||||
light: &gc_formats::tfrag::TfragLight,
|
||||
select: u16,
|
||||
) -> Option<[u8; 4]> {
|
||||
Some(light_vertex_terms(bank, n, light, select)?.out)
|
||||
}
|
||||
|
||||
/// Light one vertex, keeping the terms for the comparison.
|
||||
pub fn light_vertex_terms(
|
||||
bank: &LightBank,
|
||||
n: V4,
|
||||
light: &gc_formats::tfrag::TfragLight,
|
||||
select: u16,
|
||||
) -> Option<VertexTerms> {
|
||||
let s = active_set(bank, select)?;
|
||||
let da = dot3(n, s.da);
|
||||
let db = dot3(n, s.db);
|
||||
let da = ps2::max(da, ps2::mul(da, s.wa));
|
||||
let db = ps2::max(db, ps2::mul(db, s.wb));
|
||||
let acc = vmuls(color_float(pext5(light.color as u16)), ps2::ONE);
|
||||
let acc = vmadds(acc, s.ca, da);
|
||||
Some(pack_color(vmadds(acc, s.cb, db)))
|
||||
let da_max = ps2::max(da, ps2::mul(da, s.wa));
|
||||
let db_max = ps2::max(db, ps2::mul(db, s.wb));
|
||||
let base = pext5(light.color as u16);
|
||||
let acc = vmuls(color_float(base), ps2::ONE);
|
||||
let acc = vmadds(acc, s.ca, da_max);
|
||||
let out = pack_color(vmadds(acc, s.cb, db_max));
|
||||
let unbits = |v: V4| v.map(f32::from_bits);
|
||||
Some(VertexTerms {
|
||||
azimuth: light.azimuth_index(),
|
||||
elevation: light.elevation_index(),
|
||||
base,
|
||||
select,
|
||||
set_index: if select & 0xff00 == 0 {
|
||||
bank.sets
|
||||
.get((select & 0xf) as usize)
|
||||
.map(|_| (select & 0xf) as usize)
|
||||
} else {
|
||||
let low = bank.sets.get((select & 0xf) as usize);
|
||||
let high = bank.sets.get(((select >> 4) & 0xf) as usize);
|
||||
low.and(high).map(|_| (select & 0xf) as usize)
|
||||
},
|
||||
blended: select & 0xff00 != 0,
|
||||
normal: unbits(n),
|
||||
color_a: unbits(s.ca),
|
||||
color_b: unbits(s.cb),
|
||||
dir_a: unbits(s.da),
|
||||
dir_b: unbits(s.db),
|
||||
wa: f32::from_bits(s.wa),
|
||||
wb: f32::from_bits(s.wb),
|
||||
dot_a: f32::from_bits(da),
|
||||
dot_b: f32::from_bits(db),
|
||||
max_a: f32::from_bits(da_max),
|
||||
max_b: f32::from_bits(db_max),
|
||||
out,
|
||||
})
|
||||
}
|
||||
|
||||
/// The normal a vertex lights with, as raw PS2 floats.
|
||||
///
|
||||
/// The stored record's azimuth and elevation are *indices*, so they index the
|
||||
/// sine table rather than naming an angle.
|
||||
///
|
||||
/// All three components are negated: the stored normals point into the
|
||||
/// surface. The z sign is what the PCSX2 comparison settled -- negating only x
|
||||
/// and y still produces a plausible level, and it cost 48 percentage points of
|
||||
/// agreement on level 0 (95.88% vs 100.00%) before it was measured.
|
||||
pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4 {
|
||||
let (ca, sa) = table.get(azimuth);
|
||||
let (ce, se) = table.get(elevation);
|
||||
@@ -173,22 +256,23 @@ pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4
|
||||
[
|
||||
ps2::sub(0, x),
|
||||
ps2::sub(0, y),
|
||||
ps2::add(0, se.to_bits()),
|
||||
ps2::sub(0, se.to_bits()),
|
||||
se.to_bits(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Run the lighting pass over a patch's light records.
|
||||
///
|
||||
/// Returns one colour per light record, in the same order. Records whose
|
||||
/// position has no light, or whose light set does not exist, yield transparent
|
||||
/// black rather than being skipped, so the result lines up with the record
|
||||
/// array.
|
||||
/// Returns one colour per light record, in the same order. A record whose
|
||||
/// `select` names a light set the bank does not have is left at its base
|
||||
/// colour, which is what the game writes: the PCSX2 comparison shows those
|
||||
/// records' RAM colours equal to their PEXT5 base exactly. Transparent black
|
||||
/// was the first guess and it was wrong -- the vertex is unlit, not invisible.
|
||||
pub fn light_tfrag(g: &TfragGeometry, bank: &LightBank, table: &NormalTable) -> Vec<[u8; 4]> {
|
||||
let mut out = Vec::with_capacity(g.lights.len());
|
||||
for l in &g.lights {
|
||||
let n = vertex_normal_bits(table, l.azimuth_index(), l.elevation_index());
|
||||
out.push(light_vertex_n(bank, n, l, l.select).unwrap_or([0, 0, 0, 0]));
|
||||
out.push(light_vertex_n(bank, n, l, l.select).unwrap_or(pext5(l.color as u16)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -273,11 +357,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_bank_lights_nothing() {
|
||||
fn an_empty_bank_leaves_the_base_colour() {
|
||||
// The game's own answer, over PINE: a record whose select names a set
|
||||
// the bank does not have keeps its base colour. Transparent black was
|
||||
// the first guess, and the comparison read it as a 4.4% mismatch on
|
||||
// level 0 before this was measured.
|
||||
let bank = LightBank::default();
|
||||
let t = table();
|
||||
let out = light_tfrag(&patch(vec![light(0)]), &bank, &t);
|
||||
assert_eq!(out, vec![[0, 0, 0, 0]]);
|
||||
let mut l = light(0);
|
||||
// 5:5:5:1 with the top red step and the alpha bit set: (8, 0, 0,
|
||||
// 128). Stored as the i16 the record carries, expanded by PEXT5.
|
||||
l.color = 0x8001u16.cast_signed();
|
||||
let out = light_tfrag(&patch(vec![l]), &bank, &t);
|
||||
// PEXT5 of 1 is 1 << 3 = 8, with the alpha bit at 0x80.
|
||||
assert_eq!(out, vec![[8, 0, 0, 128]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -289,4 +382,21 @@ mod tests {
|
||||
assert_eq!(n[1], 0);
|
||||
assert_eq!(n[2], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_normal_s_z_is_negated_like_x_and_y() {
|
||||
// Elevation 64 is straight up: (0, 0, 1) before negation, so the
|
||||
// stored normal points down into the surface. Getting the z sign
|
||||
// wrong still produces a plausible level -- it flips which vertices
|
||||
// a light reaches -- and it cost 48 percentage points of agreement
|
||||
// before the PCSX2 comparison caught it.
|
||||
let t = table();
|
||||
let n = vertex_normal_bits(&t, 0, 64);
|
||||
let f = |b: u32| f32::from_bits(b);
|
||||
// The table's cos(pi/2) is 1.5e-8 rather than exactly zero, which is
|
||||
// what the game's own table holds, so x and y are ~0 rather than 0.
|
||||
assert!(f(n[0]).abs() < 1e-6, "x should be ~0, got {}", f(n[0]));
|
||||
assert!(f(n[1]).abs() < 1e-6, "y should be ~0, got {}", f(n[1]));
|
||||
assert_eq!(n[2], f32::to_bits(-1.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ pub fn probe_level(dir: &Path, level: u32) -> Result<LevelProbe> {
|
||||
header.push((name.into(), range(v)));
|
||||
}
|
||||
|
||||
let regions = gc_formats::regions::core_layout(&index, &h, core.len());
|
||||
let regions = gc_formats::regions::core_layout(&index, &core, &h);
|
||||
|
||||
let mut classes = Vec::new();
|
||||
for (name, r, stride) in [
|
||||
|
||||
+394
-83
@@ -1620,11 +1620,17 @@ fn moby_parts_tile_on_every_level() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shrub class headers.
|
||||
/// Shrub class headers and blobs.
|
||||
///
|
||||
/// Two fields self-identify exactly on every span, and the parser checks both
|
||||
/// rather than merely reporting them: `+0x24` must equal the owning class
|
||||
/// entry's own `o_class`, and `+0x2c` must land inside the span.
|
||||
///
|
||||
/// The body is then checked against the R&C1 shape this format follows: a
|
||||
/// palette of 24 unit-length `s16` normals with a zero pad word, then zero
|
||||
/// padding records. And the billboard offset at `+0x1c` is checked against the
|
||||
/// class entry's billboard texture fields, which is the one header field that
|
||||
/// can be confirmed from a second file.
|
||||
#[test]
|
||||
fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
require_disc!();
|
||||
@@ -1640,15 +1646,24 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
}
|
||||
|
||||
use gc_formats::level_core::ShrubClassEntry;
|
||||
use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES};
|
||||
use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES, SHRUB_NORMAL_COUNT};
|
||||
let mut levels = 0usize;
|
||||
let mut spans = 0usize;
|
||||
let mut absent = 0usize;
|
||||
let (mut min_size, mut max_size) = (f32::MAX, 0.0f32);
|
||||
let mut denormals = 0usize;
|
||||
let mut scratch_nonzero = 0usize;
|
||||
let mut mode_hist: std::collections::BTreeMap<u16, usize> = Default::default();
|
||||
let mut scratch_hist: std::collections::BTreeMap<u16, usize> = Default::default();
|
||||
let mut radius_range = (f32::MAX, 0f32);
|
||||
let mut scale_range = (f32::MAX, 0f32);
|
||||
let mut bounded_records = 0usize;
|
||||
let mut bounded_bodies = 0usize;
|
||||
let mut last_bodies = 0usize;
|
||||
let mut palette_records = 0usize;
|
||||
let mut padding_records = 0usize;
|
||||
let mut norm2_deviation = 0i64;
|
||||
let mut billboards = 0usize;
|
||||
let mut billboard_entries = 0usize;
|
||||
let mut packets_total = 0usize;
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
@@ -1661,10 +1676,10 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
let h = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header");
|
||||
let range = h.shrub_classes;
|
||||
// Class spans tile between consecutive geometry offsets, with the last
|
||||
// one bounded by the start of core_data's constant trailer. Without
|
||||
// this the last class absorbs the trailer and the body check measures
|
||||
// its neighbours.
|
||||
let trailer = gc_formats::regions::CORE_DATA_TRAILER_BYTES;
|
||||
// one bounded by the chunk container. It is found by signature, since
|
||||
// the core index carries no successor offset for shrub; without it the
|
||||
// last class absorbs the container and the body check measures its
|
||||
// neighbours.
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
for i in 0..range.count.max(0) as usize {
|
||||
let at = range.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
||||
@@ -1675,48 +1690,45 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
}
|
||||
}
|
||||
offsets.sort_unstable();
|
||||
// Levels without shrub geometry have no span to bound. The container
|
||||
// that follows tie on those levels is checked by
|
||||
// `chunk_container_closes_the_last_class_region`.
|
||||
let container = match offsets.last() {
|
||||
Some(&last) if !offsets.is_empty() => {
|
||||
gc_formats::chunks::chunk_region_start(&core, last).unwrap_or_else(|| {
|
||||
panic!("level{n}: no chunk container after the shrub classes")
|
||||
})
|
||||
}
|
||||
_ => core.len(),
|
||||
};
|
||||
let mut span_end_of: std::collections::HashMap<usize, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for (i, &o) in offsets.iter().enumerate() {
|
||||
let end = offsets.get(i + 1).copied().unwrap_or(core.len() - trailer);
|
||||
let end = offsets.get(i + 1).copied().unwrap_or(container);
|
||||
span_end_of.insert(o, end);
|
||||
}
|
||||
for i in 0..range.count.max(0) as usize {
|
||||
let at = range.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
||||
let e = ShrubClassEntry::parse(&index[at..at + SHRUB_CLASS_ENTRY_BYTES])
|
||||
.unwrap_or_else(|e| panic!("level{n}: shrub class {i}: {e}"));
|
||||
let (span_end, has_successor) = match span_end_of
|
||||
.get(&(e.base.offset_in_asset_wad.max(0) as usize))
|
||||
{
|
||||
Some(&e2)
|
||||
if offsets.last() != Some(&(e.base.offset_in_asset_wad.max(0) as usize)) =>
|
||||
{
|
||||
(e2, true)
|
||||
}
|
||||
_ => (core.len(), false),
|
||||
let (span_end, has_successor) =
|
||||
match span_end_of.get(&(e.base.offset_in_asset_wad.max(0) as usize)) {
|
||||
Some(&e2) => (e2, true),
|
||||
None => (core.len(), false),
|
||||
};
|
||||
match ShrubHeader::region(&core, e.base.offset_in_asset_wad, e.base.o_class, span_end) {
|
||||
Ok(Some(mut s)) => {
|
||||
s.span_end_is_a_successor = has_successor;
|
||||
// Only a class whose span ends at the *next* class has a
|
||||
// bounded body. The last class's span runs to the trailer
|
||||
// and contains the 690 KB region that follows shrub, so its
|
||||
// body is not wholly records -- that is the open question,
|
||||
// not a defect, and asserting either way would be wrong.
|
||||
if s.span_end_is_a_successor {
|
||||
// Every class is bounded now, so every body must be whole
|
||||
// 8-byte records. That was not assertable while the last
|
||||
// class ran to the lump's end, and it is the check the
|
||||
// boundary earns its keep for.
|
||||
assert!(
|
||||
s.body_is_well_formed(),
|
||||
"level{n} class {i}: bounded shrub body is not whole 8-byte records"
|
||||
"level{n} class {i}: shrub body is not whole 8-byte records"
|
||||
);
|
||||
bounded_bodies += 1;
|
||||
}
|
||||
last_bodies += 1;
|
||||
// Count records only where the span is actually bounded.
|
||||
// Summing the unbounded last class too reproduces the
|
||||
// retracted "3.5 million records" figure.
|
||||
if s.span_end_is_a_successor {
|
||||
bounded_records += s.body_record_count();
|
||||
}
|
||||
assert_eq!(
|
||||
s.header.o_class,
|
||||
e.base.o_class.max(0) as u32,
|
||||
@@ -1724,26 +1736,96 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
);
|
||||
assert!(!s.data.is_empty(), "level{n} class {i}: empty shrub data");
|
||||
assert!(
|
||||
s.header.bounds.iter().all(|v| v.is_finite()),
|
||||
"level{n} class {i}: non-finite shrub bounds"
|
||||
s.header.sphere.iter().all(|v| v.is_finite()),
|
||||
"level{n} class {i}: non-finite shrub sphere"
|
||||
);
|
||||
assert!(
|
||||
s.header.size.is_finite() && s.header.size > 0.0,
|
||||
"level{n} class {i}: shrub size {}",
|
||||
s.header.size
|
||||
);
|
||||
// The PS2 has no denormals, so a denormal here and a zero
|
||||
// are the same value to the hardware. Both occur on disc.
|
||||
if s.header.reserved != 0.0 {
|
||||
assert!(
|
||||
s.header.reserved.is_subnormal(),
|
||||
"level{n} class {i}: reserved {} is neither zero nor denormal",
|
||||
s.header.reserved
|
||||
);
|
||||
denormals += 1;
|
||||
// The scratch word is zero on every span; the parser
|
||||
// refuses anything else. `mode_bits` is 4 on 16 spans and
|
||||
// is reported rather than asserted. On those 16 the word
|
||||
// at +0x14 reads as an f32 denormal, which the Emotion
|
||||
// Engine flushes to zero like everything else.
|
||||
if s.header.scratch != 0 {
|
||||
scratch_nonzero += 1;
|
||||
}
|
||||
*mode_hist.entry(s.header.mode_bits).or_default() += 1;
|
||||
*scratch_hist.entry(s.header.scratch).or_default() += 1;
|
||||
radius_range.0 = radius_range.0.min(s.header.sphere[3]);
|
||||
radius_range.1 = radius_range.1.max(s.header.sphere[3]);
|
||||
scale_range.0 = scale_range.0.min(s.header.scale);
|
||||
scale_range.1 = scale_range.1.max(s.header.scale);
|
||||
min_size = min_size.min(s.header.size);
|
||||
max_size = max_size.max(s.header.size);
|
||||
|
||||
// The palette: 24 unit normals, then zero padding.
|
||||
let normals: Vec<_> = s.normals().collect();
|
||||
assert_eq!(
|
||||
normals.len(),
|
||||
SHRUB_NORMAL_COUNT,
|
||||
"level{n} class {i}: palette is {} entries, not {SHRUB_NORMAL_COUNT}",
|
||||
normals.len()
|
||||
);
|
||||
for (k, normal) in normals.iter().enumerate() {
|
||||
assert!(
|
||||
normal.is_unit(),
|
||||
"level{n} class {i}: palette entry {k} {normal:?} is not a unit normal"
|
||||
);
|
||||
let n2 = (normal.x as i64).pow(2)
|
||||
+ (normal.y as i64).pow(2)
|
||||
+ (normal.z as i64).pow(2);
|
||||
norm2_deviation = norm2_deviation.max((n2 - 32767i64.pow(2)).abs());
|
||||
}
|
||||
palette_records += normals.len();
|
||||
for (k, record) in s.body_records().skip(SHRUB_NORMAL_COUNT).enumerate() {
|
||||
assert!(
|
||||
record.iter().all(|&b| b == 0),
|
||||
"level{n} class {i}: padding record {k} is not all zero"
|
||||
);
|
||||
}
|
||||
padding_records += s.padding_record_count();
|
||||
|
||||
// The packets: the table at +0x40 tiles the span from
|
||||
// align16 of its own end to the palette, or to the
|
||||
// billboard when the class has one. This is what puts the
|
||||
// mesh geometry in the blob, and it is the identity that
|
||||
// makes `count` the packet count rather than a guess.
|
||||
let packets = s.packets().unwrap_or_else(|| {
|
||||
panic!("level{n} class {i}: packet table does not tile")
|
||||
});
|
||||
assert_eq!(
|
||||
packets.len(),
|
||||
s.header.count as usize,
|
||||
"level{n} class {i}: packet count disagrees with the header"
|
||||
);
|
||||
packets_total += packets.len();
|
||||
|
||||
// The billboard: present exactly when the class entry
|
||||
// carries billboard texture fields.
|
||||
let entry_has_billboard = e.billboard_width != 0 || e.billboard_height != 0;
|
||||
match s.billboard().expect("billboard parses") {
|
||||
Some(b) => {
|
||||
billboards += 1;
|
||||
assert!(
|
||||
entry_has_billboard,
|
||||
"level{n} class {i}: billboard record without billboard texture fields"
|
||||
);
|
||||
assert_eq!(b.tex1_addr, 0x14, "level{n} class {i}: TEX1 address");
|
||||
assert_eq!(b.tex0_addr, 0x06, "level{n} class {i}: TEX0 address");
|
||||
assert_eq!(b.miptbp_addr, 0x34, "level{n} class {i}: MIPTBP address");
|
||||
assert_eq!(b.mmin, 4, "level{n} class {i}: MMIN");
|
||||
}
|
||||
None => assert!(
|
||||
!entry_has_billboard,
|
||||
"level{n} class {i}: billboard texture fields with no billboard record"
|
||||
),
|
||||
}
|
||||
if entry_has_billboard {
|
||||
billboard_entries += 1;
|
||||
}
|
||||
spans += 1;
|
||||
}
|
||||
Ok(None) => absent += 1,
|
||||
@@ -1761,27 +1843,38 @@ fn shrub_headers_name_their_own_class_on_every_level() {
|
||||
spans + absent
|
||||
);
|
||||
assert!(spans > 340, "only {spans} shrub spans with geometry");
|
||||
// The container bounds every class now, so every body is checkable.
|
||||
assert_eq!(bounded_bodies, spans, "every shrub class should be bounded");
|
||||
assert_eq!(scratch_nonzero, 0, "the scratch word is zero on every span");
|
||||
assert_eq!(
|
||||
billboards, billboard_entries,
|
||||
"billboard records and billboard class entries disagree: \
|
||||
{billboards} records vs {billboard_entries} entries"
|
||||
);
|
||||
eprintln!(
|
||||
"shrub: {spans} headers naming their own class, {absent} without geometry; \
|
||||
{bounded_bodies}/{last_bodies} class bodies bounded by a successor, \
|
||||
{bounded_records} 8-byte records in bounded bodies, \
|
||||
{denormals} denormal reserved words"
|
||||
all {bounded_bodies} bodies bounded, {bounded_records} records, \
|
||||
{palette_records} of them palette normals and {padding_records} zero padding; \
|
||||
{billboards} billboards, all with texture fields; \
|
||||
{packets_total} packets across all blobs; \
|
||||
mode_bits {mode_hist:?}, radius {radius_range:?}, scale {scale_range:?}, \
|
||||
largest palette deviation from unit length {norm2_deviation} of a possible 65535"
|
||||
);
|
||||
}
|
||||
|
||||
/// Region bounds derived from successor boundaries, on every level.
|
||||
///
|
||||
/// The point is not that these are correct -- shrub's end is still unknown --
|
||||
/// but that the unknown is visible. Every region here is closed by the next
|
||||
/// region's start rather than by `core_data.len()`, so no span silently
|
||||
/// absorbs the tail of the lump the way the last shrub class was doing.
|
||||
/// Every region is closed by the next region's start, or -- for the last class
|
||||
/// region -- by the located chunk container, never by `core_data.len()`, so no
|
||||
/// span silently absorbs the tail of the lump the way the last shrub class was
|
||||
/// doing.
|
||||
#[test]
|
||||
fn core_regions_chain_on_every_level() {
|
||||
require_disc!();
|
||||
let data = std::env::var_os("GC_EXTRACTED")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join("Documents/gc-extracted")
|
||||
});
|
||||
if !data.exists() {
|
||||
@@ -1790,23 +1883,26 @@ fn core_regions_chain_on_every_level() {
|
||||
}
|
||||
|
||||
use gc_formats::level_core::LevelCoreHeader;
|
||||
use gc_formats::regions::core_layout;
|
||||
use gc_formats::regions::{core_layout, CORE_DATA_TRAILER_BYTES};
|
||||
let mut levels = 0usize;
|
||||
let mut unresolved_shrub = 0usize;
|
||||
let mut unresolved_chunks = 0usize;
|
||||
let mut bounded = 0usize;
|
||||
let mut shrub_bytes = 0usize;
|
||||
let mut chunk_bytes = 0usize;
|
||||
let (mut first, mut last) = (usize::MAX, 0usize);
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
let Ok(index) = std::fs::read(dir.join("core_index.bin")) else {
|
||||
let (Ok(core), Ok(index)) = (
|
||||
std::fs::read(dir.join("core_data.bin")),
|
||||
std::fs::read(dir.join("core_index.bin")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let core_len = std::fs::metadata(dir.join("core_data.bin"))
|
||||
.map(|m| m.len() as usize)
|
||||
.unwrap_or(0);
|
||||
let core_len = core.len();
|
||||
let h = LevelCoreHeader::parse(&index).expect("core header");
|
||||
let regions = core_layout(&index, &h, core_len);
|
||||
let regions = core_layout(&index, &core, &h);
|
||||
|
||||
// Regions appear in order, never overlap, and never run past the lump.
|
||||
for pair in regions.windows(2) {
|
||||
@@ -1838,7 +1934,7 @@ fn core_regions_chain_on_every_level() {
|
||||
|
||||
// Regions appear in the canonical order, but not every level has every
|
||||
// region -- level 10 has no occlusion and no shrub at all.
|
||||
const ORDER: [&str; 7] = [
|
||||
const ORDER: [&str; 8] = [
|
||||
"tfrags",
|
||||
"occlusion",
|
||||
"sky",
|
||||
@@ -1846,6 +1942,7 @@ fn core_regions_chain_on_every_level() {
|
||||
"moby",
|
||||
"tie",
|
||||
"shrub",
|
||||
"chunks",
|
||||
];
|
||||
let names: Vec<_> = regions.iter().map(|r| r.name).collect();
|
||||
let ranks: Vec<usize> = names
|
||||
@@ -1862,11 +1959,11 @@ fn core_regions_chain_on_every_level() {
|
||||
"level{n}: regions out of order: {names:?}"
|
||||
);
|
||||
if let Some(s) = regions.iter().find(|r| r.name == "shrub") {
|
||||
// The constant trailer bounds it; assert it is actually inside
|
||||
// the lump rather than at its end.
|
||||
match s.end {
|
||||
Some(e) => {
|
||||
assert!(e < core_len, "level{n}: shrub ends at the lump's end");
|
||||
// The container's start, not the trailer and not the lump's
|
||||
// end: the trailer is the chunk region's business.
|
||||
assert!(e < core_len - CORE_DATA_TRAILER_BYTES);
|
||||
shrub_bytes += s.len().unwrap_or(0);
|
||||
bounded += 1;
|
||||
}
|
||||
@@ -1875,20 +1972,33 @@ fn core_regions_chain_on_every_level() {
|
||||
first = first.min(s.start);
|
||||
last = last.max(s.start);
|
||||
}
|
||||
if let Some(c) = regions.iter().find(|r| r.name == "chunks") {
|
||||
match c.end {
|
||||
Some(e) => {
|
||||
// The trailer is the only thing past the chunk container.
|
||||
assert_eq!(e, core_len - CORE_DATA_TRAILER_BYTES);
|
||||
chunk_bytes += c.len().unwrap_or(0);
|
||||
}
|
||||
None => unresolved_chunks += 1,
|
||||
}
|
||||
}
|
||||
levels += 1;
|
||||
}
|
||||
|
||||
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
||||
// Known gap, not a bug: the offset ending the shrub region is not in the
|
||||
// core index and textures sit before moby, so they cannot bound it.
|
||||
assert_eq!(
|
||||
unresolved_shrub, 0,
|
||||
"every level with shrub should bound it"
|
||||
"every level with shrub should bound it at the chunk container"
|
||||
);
|
||||
assert_eq!(bounded, 24, "expected 24 levels with a shrub region");
|
||||
assert_eq!(
|
||||
unresolved_chunks, 0,
|
||||
"every chunk region should end at the trailer"
|
||||
);
|
||||
eprintln!(
|
||||
"regions: shrub starts {first}..{last}; {shrub_bytes} bytes total, \
|
||||
bounded by the constant trailer on all {bounded} levels"
|
||||
"regions: shrub starts {first}..{last}; {shrub_bytes} bytes of shrub and \
|
||||
{chunk_bytes} bytes of chunk data, shrub closed by the located container on \
|
||||
all {bounded} levels"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1896,6 +2006,15 @@ fn core_regions_chain_on_every_level() {
|
||||
/// signature on every level, and their offsets resolve to records with a
|
||||
/// shared field layout.
|
||||
///
|
||||
/// The region is bounded properly rather than by the fixed window this test
|
||||
/// first used: it runs from the marker-paired chunk signature that closes the
|
||||
/// last class region to the constant trailer. The window approach worked and
|
||||
/// then over-worked -- a fixed 900,000 bytes from the end reaches past the
|
||||
/// container on some levels and stops inside it on others, which is how 27
|
||||
/// chunks came to be truncated at a "window edge" that is not a format
|
||||
/// boundary. The locator is asserted by
|
||||
/// `chunk_container_closes_the_last_class_region`.
|
||||
///
|
||||
/// The layout facts asserted here are measured, not assumed:
|
||||
///
|
||||
/// - every tagged offset has top byte `0xf0`, on all 27 levels;
|
||||
@@ -1909,7 +2028,7 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
let data = std::env::var_os("GC_EXTRACTED")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join("Documents/gc-extracted")
|
||||
});
|
||||
if !data.exists() {
|
||||
@@ -1917,6 +2036,9 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
return;
|
||||
}
|
||||
|
||||
use gc_formats::level_core::LevelCoreHeader;
|
||||
use gc_formats::regions::{core_layout, CORE_DATA_TRAILER_BYTES};
|
||||
|
||||
let mut levels = 0usize;
|
||||
let mut chunks = 0usize;
|
||||
let mut records = 0usize;
|
||||
@@ -1928,20 +2050,30 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
let core = match std::fs::read(dir.join("core_data.bin")) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
let (Ok(core), Ok(index)) = (
|
||||
std::fs::read(dir.join("core_data.bin")),
|
||||
std::fs::read(dir.join("core_index.bin")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// The region runs from after the last shrub geometry to the constant
|
||||
// trailer, whose size is measured and identical on all 27 levels.
|
||||
const TRAILER: usize = 0x38a10;
|
||||
let region_start = core.len().saturating_sub(TRAILER + 900_000);
|
||||
let region_end = core.len() - TRAILER;
|
||||
if region_end <= region_start {
|
||||
// The region runs from the chunk signature that closes the last class
|
||||
// region to the constant trailer, both measured and identical in size
|
||||
// on all 27 levels. core_layout locates the start through the same
|
||||
// signature the chunk parser uses.
|
||||
let h = LevelCoreHeader::parse(&index).expect("core header");
|
||||
let Some(region) = core_layout(&index, &core, &h)
|
||||
.into_iter()
|
||||
.find(|r| r.name == "chunks")
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
let region = &core[region_start..region_end];
|
||||
};
|
||||
let Some(end) = region.end else {
|
||||
panic!("level{n}: chunk region is unbounded");
|
||||
};
|
||||
assert_eq!(end, core.len() - CORE_DATA_TRAILER_BYTES);
|
||||
assert!(!region.is_empty(), "level{n}: empty chunk region");
|
||||
let region = &core[region.start..end];
|
||||
|
||||
let found = gc_formats::chunks::parse_all_chunks(region).expect("chunks should parse");
|
||||
if found.is_empty() {
|
||||
@@ -1970,9 +2102,9 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
strides.insert(chunk.chunk.stride);
|
||||
|
||||
// Records resolve unless the last one would run past the region
|
||||
// end -- which happens for chunks sitting at the window edge, since
|
||||
// the window is a fixed 900000 bytes and does not align to chunk
|
||||
// boundaries. Truncation is counted rather than asserted away.
|
||||
// end, which happens for the chunk sitting at the trailer end:
|
||||
// the container's records stop there, so its final record or two
|
||||
// fall outside. Truncation is counted rather than asserted away.
|
||||
assert!(
|
||||
chunk.records.len() <= chunk.chunk.offsets.len(),
|
||||
"level{n}: chunk at {} resolved MORE records than offsets",
|
||||
@@ -1993,7 +2125,7 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
}
|
||||
}
|
||||
|
||||
assert!(levels >= 20, "only {levels} levels had chunks");
|
||||
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
||||
assert!(chunks > 500, "only {chunks} chunks resolved");
|
||||
assert!(records > 5_000, "only {records} records resolved");
|
||||
assert!(
|
||||
@@ -2011,7 +2143,7 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
eprintln!(
|
||||
"chunks: {chunks} over {levels} levels, {records} records, {} distinct strides, \
|
||||
constant {constant_total}/{stride_total} ({:.1}%), per-chunk min {:.1}% max {:.1}%, \
|
||||
{truncated} chunks truncated at the window edge",
|
||||
{truncated} chunks truncated at the trailer end",
|
||||
strides.len(),
|
||||
mean_frac * 100.0,
|
||||
frac.iter().cloned().fold(f64::INFINITY, f64::min) * 100.0,
|
||||
@@ -2019,6 +2151,185 @@ fn chunk_containers_resolve_on_every_level() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The chunk container closes the last class region, on every level.
|
||||
///
|
||||
/// The core index carries no successor offset for shrub -- unlike moby and
|
||||
/// tie, whose boundaries fall out of the class tables -- so the boundary is
|
||||
/// located in `core_data`: the constant marker pair sitting immediately in
|
||||
/// front of a chunk signature. Both halves of that signature are weak alone,
|
||||
/// which is why the test measures them separately:
|
||||
///
|
||||
/// - the marker pair `f6 7c` occurs 25 to 103 times in a 5 to 17 MB lump, so
|
||||
/// on its own it means nothing;
|
||||
/// - the chunk shape (16-byte header ending `0xff`, 10 zeros, tagged offsets)
|
||||
/// matches random data often enough to need four tagged offsets before it
|
||||
/// means anything.
|
||||
///
|
||||
/// Together they are exact: on all 27 levels the marker sits exactly two bytes
|
||||
/// before the first chunk header. Three checks pin that down per level:
|
||||
///
|
||||
/// 1. the located boundary carries the marker;
|
||||
/// 2. the last shrub body walks to 8-byte records that end exactly where the
|
||||
/// signature begins, with no chunk signature inside the body itself;
|
||||
/// 3. the container's records fill the region up to the constant trailer.
|
||||
///
|
||||
/// Levels without shrub are the control for the locator: there the container
|
||||
/// closes tie instead, and the same marker precedes it.
|
||||
#[test]
|
||||
fn chunk_container_closes_the_last_class_region() {
|
||||
require_disc!();
|
||||
let data = std::env::var_os("GC_EXTRACTED")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join("Documents/gc-extracted")
|
||||
});
|
||||
if !data.exists() {
|
||||
eprintln!("skipped: unpack the disc first");
|
||||
return;
|
||||
}
|
||||
|
||||
use gc_formats::chunks::{self, CHUNK_REGION_MARKER};
|
||||
use gc_formats::level_core::{LevelCoreHeader, ShrubClassEntry};
|
||||
use gc_formats::regions::{core_layout, last_geometry, CORE_DATA_TRAILER_BYTES};
|
||||
use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES};
|
||||
|
||||
let mut levels = 0usize;
|
||||
let mut with_shrub = 0usize;
|
||||
let mut marker_wide_max = 0usize;
|
||||
let mut body_records = 0usize;
|
||||
let mut container_bytes = 0usize;
|
||||
let mut fill_gap_max = 0usize;
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
let (Ok(core), Ok(index)) = (
|
||||
std::fs::read(dir.join("core_data.bin")),
|
||||
std::fs::read(dir.join("core_index.bin")),
|
||||
) else {
|
||||
panic!("level{n}: missing core_data or core_index");
|
||||
};
|
||||
let trailer = core.len() - CORE_DATA_TRAILER_BYTES;
|
||||
let h = LevelCoreHeader::parse(&index).expect("core header");
|
||||
|
||||
let last_shrub = last_geometry(&index, h.shrub_classes, SHRUB_CLASS_ENTRY_BYTES);
|
||||
let last_tie = last_geometry(&index, h.tie_classes, 0x20);
|
||||
// The container follows the last of the shrub and tie classes, so the
|
||||
// signature search starts after the class bodies rather than inside
|
||||
// them.
|
||||
let scan_from = last_shrub
|
||||
.or(last_tie)
|
||||
.unwrap_or_else(|| panic!("level{n}: no class geometry to follow"));
|
||||
let container = chunks::chunk_region_start(&core, scan_from)
|
||||
.unwrap_or_else(|| panic!("level{n}: no chunk container after {scan_from}"));
|
||||
|
||||
// 1. The marker, and how common it is without the signature behind it.
|
||||
assert_eq!(
|
||||
&core[container - 2..container],
|
||||
&CHUNK_REGION_MARKER,
|
||||
"level{n}: container at {container} is not preceded by the marker"
|
||||
);
|
||||
marker_wide_max = marker_wide_max.max(
|
||||
core.windows(2)
|
||||
.filter(|w| *w == CHUNK_REGION_MARKER)
|
||||
.count(),
|
||||
);
|
||||
|
||||
// 2. The last shrub body ends exactly at the marker. This is the check
|
||||
// that the boundary is the successor of shrub rather than a coincident
|
||||
// pair inside it.
|
||||
if let Some(last) = last_shrub {
|
||||
let o_class = (0..h.shrub_classes.count.max(0) as usize)
|
||||
.filter_map(|i| {
|
||||
let at = h.shrub_classes.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES;
|
||||
let e = ShrubClassEntry::parse(
|
||||
index
|
||||
.get(at..at + SHRUB_CLASS_ENTRY_BYTES)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.ok()?;
|
||||
(e.base.offset_in_asset_wad == last as i32).then_some(e.base.o_class)
|
||||
})
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("level{n}: no shrub class at {last}"));
|
||||
let span = ShrubHeader::region(&core, last as i32, o_class, trailer)
|
||||
.expect("last shrub span parses")
|
||||
.expect("last shrub span present");
|
||||
let data_start = last + span.header.data_offset as usize;
|
||||
|
||||
// Walk 8-byte records, bounded by the container so the walk can
|
||||
// never continue into the chunk region itself.
|
||||
let mut at = data_start;
|
||||
while at + 8 <= container && core[at + 6] == 0 && core[at + 7] == 0 {
|
||||
at += 8;
|
||||
}
|
||||
assert_eq!(
|
||||
at + 2,
|
||||
container,
|
||||
"level{n}: shrub body at {data_start} does not end at the marker"
|
||||
);
|
||||
assert!(at > data_start, "level{n}: last shrub body is empty");
|
||||
body_records += (at - data_start) / 8;
|
||||
|
||||
// No chunk signature inside the body: the search for the boundary
|
||||
// must not match one it should have skipped.
|
||||
let sig_inside = (last..container - 2).any(|off| chunks::is_chunk_start(&core, off, 4));
|
||||
assert!(
|
||||
!sig_inside,
|
||||
"level{n}: chunk signature inside the last shrub body"
|
||||
);
|
||||
with_shrub += 1;
|
||||
} else {
|
||||
// Without shrub the container closes tie, and the marker precedes
|
||||
// it the same way -- the locator is not shrub-specific.
|
||||
let tie = core_layout(&index, &core, &h)
|
||||
.into_iter()
|
||||
.find(|r| r.name == "tie")
|
||||
.unwrap_or_else(|| panic!("level{n}: no tie region to close"));
|
||||
assert_eq!(
|
||||
tie.end,
|
||||
Some(container),
|
||||
"level{n}: tie must end at the container"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. The container fills its region up to the constant trailer: the
|
||||
// furthest record should reach within a few KB of it, never inside it.
|
||||
let region = &core[container..trailer];
|
||||
let found = chunks::parse_all_chunks(region).expect("chunks should parse");
|
||||
let region_ptr = region.as_ptr() as usize;
|
||||
let furthest = found
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.records
|
||||
.iter()
|
||||
.map(move |r| r.as_ptr() as usize + r.len() - region_ptr)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
region.len() - furthest < 4096,
|
||||
"level{n}: chunk records stop {} bytes short of the trailer",
|
||||
region.len() - furthest
|
||||
);
|
||||
container_bytes += region.len();
|
||||
fill_gap_max = fill_gap_max.max(region.len() - furthest);
|
||||
levels += 1;
|
||||
}
|
||||
|
||||
assert_eq!(levels, 27, "expected all 27 levels, got {levels}");
|
||||
assert_eq!(
|
||||
with_shrub, 24,
|
||||
"expected 24 levels with shrub, got {with_shrub}"
|
||||
);
|
||||
eprintln!(
|
||||
"container: {levels} levels ({with_shrub} closing shrub), \
|
||||
{body_records} 8-byte records in last-shrub bodies, \
|
||||
{container_bytes} bytes of chunk data, largest trailer gap {fill_gap_max}, \
|
||||
marker pair occurs at most {marker_wide_max} times per lump"
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte positions that hold the same value in every supplied record.
|
||||
fn constant_positions(records: &[&[u8]]) -> usize {
|
||||
let Some(first) = records.first() else {
|
||||
|
||||
@@ -43,6 +43,20 @@ pub const CHUNK_GAP_BYTES: usize = 10;
|
||||
/// The value every tagged offset's top byte carries, on all 27 levels.
|
||||
pub const CHUNK_TAG: u8 = 0xf0;
|
||||
|
||||
/// The two bytes immediately preceding every chunk region on the disc.
|
||||
///
|
||||
/// The last class body in a level -- shrub where the level has shrub, tie
|
||||
/// where it does not -- ends with this constant pair before the chunk
|
||||
/// container begins. It is *not* a unique pattern: the pair occurs 25 to 103
|
||||
/// times in a 5 to 17 MB lump, so on its own it means nothing. What locates
|
||||
/// the boundary is the pair sitting exactly in front of a chunk signature
|
||||
/// (see [`chunk_region_start`]).
|
||||
///
|
||||
/// Not interpreted. Whether it is a terminator, an end count or a tag is not
|
||||
/// established; what is established is that it is constant across all 27
|
||||
/// levels and sits at this one place.
|
||||
pub const CHUNK_REGION_MARKER: [u8; 2] = [0xf6, 0x7c];
|
||||
|
||||
/// A chunk header plus the records its offsets address.
|
||||
///
|
||||
/// `stride` is the distance between the first two resolved offsets. Every
|
||||
@@ -97,6 +111,38 @@ pub fn is_chunk_start(region: &[u8], offset: usize, min_offsets: usize) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// The start of the chunk container: the first offset at or after `from`
|
||||
/// carrying a chunk signature immediately preceded by [`CHUNK_REGION_MARKER`].
|
||||
///
|
||||
/// This is what closes the last class region (shrub, or tie on the levels
|
||||
/// without shrub). The two parts of the signature are each weak alone -- the
|
||||
/// marker pair occurs by chance dozens of times per lump, and the chunk shape
|
||||
/// matches random data often enough to need four tagged offsets to be believed
|
||||
/// -- and together they are exact: on all 27 levels the marker sits exactly
|
||||
/// two bytes before the first chunk header, with no further marker-paired
|
||||
/// signature after the region start.
|
||||
///
|
||||
/// Returns `None` when the signature is not found, so an unlocated boundary
|
||||
/// stays visible rather than being closed against a guess.
|
||||
pub fn chunk_region_start(core: &[u8], from: usize) -> Option<usize> {
|
||||
if from < 2 {
|
||||
return None;
|
||||
}
|
||||
// The marker's first byte is the cheapest rejection, so test it before
|
||||
// comparing the pair or the signature behind it.
|
||||
let mut at = from - 2;
|
||||
while at + 2 <= core.len() {
|
||||
if core[at] == CHUNK_REGION_MARKER[0]
|
||||
&& core[at + 1] == CHUNK_REGION_MARKER[1]
|
||||
&& is_chunk_start(core, at + 2, 4)
|
||||
{
|
||||
return Some(at + 2);
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Little-endian, matching `Buf::u32` and the PS2.
|
||||
fn read_u32(bytes: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
|
||||
@@ -207,3 +253,62 @@ pub fn parse_all_chunks(region: &[u8]) -> Result<Vec<ResolvedChunk<'_>>> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A chunk signature at `at`, preceded by the marker: the marker pair, the
|
||||
/// 16-byte header whose last byte is `0xff`, the 10 zero bytes, then four
|
||||
/// tagged offsets.
|
||||
fn plant_chunk(core: &mut [u8], at: usize) {
|
||||
let base = at - 2;
|
||||
core[base..base + 2].copy_from_slice(&CHUNK_REGION_MARKER);
|
||||
// Header: byte 14 is the record count, matching the four tagged words.
|
||||
core[at + 14] = 4;
|
||||
core[at + 15] = 0xff;
|
||||
// The 10 zero bytes of the gap at +0x10. Their low bits resolve to a
|
||||
// zero stride, which is fine: only the signature is under test.
|
||||
core[at + CHUNK_HEADER_BYTES..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES].fill(0);
|
||||
for k in 0..4 {
|
||||
let word = (CHUNK_TAG as u32) << 24;
|
||||
core[at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4
|
||||
..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4 + 4]
|
||||
.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_markered_signature() {
|
||||
let mut core = vec![0x5au8; 256];
|
||||
plant_chunk(&mut core, 128);
|
||||
assert_eq!(chunk_region_start(&core, 64), Some(128));
|
||||
// Scanning from earlier still finds it: the signature is what locates
|
||||
// the boundary, not the search start.
|
||||
assert_eq!(chunk_region_start(&core, 2), Some(128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marker_without_a_signature_is_not_a_boundary() {
|
||||
let mut core = vec![0x5au8; 256];
|
||||
core[10..12].copy_from_slice(&CHUNK_REGION_MARKER);
|
||||
assert_eq!(chunk_region_start(&core, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_without_the_marker_is_not_a_boundary() {
|
||||
let mut core = vec![0x5au8; 256];
|
||||
// Plant the signature, then break the marker in front of it.
|
||||
plant_chunk(&mut core, 128);
|
||||
core[126] = 0x5a;
|
||||
assert_eq!(chunk_region_start(&core, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocated_boundary_stays_none() {
|
||||
assert_eq!(chunk_region_start(&[], 2), None);
|
||||
// `from` below the marker's own minimum is refused rather than
|
||||
// underflowing.
|
||||
assert_eq!(chunk_region_start(&[0xf6], 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! distributions -- were silently wrong, and nothing errored.
|
||||
//!
|
||||
//! So bounds are computed once, here, from successor boundaries, and every
|
||||
//! caller uses these rather than inventing its own. Where a bound is not yet
|
||||
//! caller uses these rather than inventing their own. Where a bound is not yet
|
||||
//! known it is `None`, not a guess: [`Region::end`] being `None` means
|
||||
//! "unresolved", and [`core_layout`] refuses to close a region whose successor
|
||||
//! it cannot find.
|
||||
@@ -24,14 +24,24 @@
|
||||
//! | sky | sky | collision |
|
||||
//! | collision | collision | first moby class |
|
||||
//! | moby | first moby | first tie class |
|
||||
//! | tie | first tie | first shrub class |
|
||||
//! | shrub | first shrub | **unresolved** |
|
||||
//! | tie | first tie | first shrub class, |
|
||||
//! | | | else the chunk container |
|
||||
//! | shrub | first shrub | the chunk container |
|
||||
//! | chunks | the chunk | lump length − 0x38A10 |
|
||||
//! | | container | |
|
||||
//!
|
||||
//! The shrub end is the open item. Texture texels sit *before* moby, not after
|
||||
//! shrub, so they cannot bound it: on level 0 the texel range is 3539328 to
|
||||
//! 5326208 while the last shrub class starts at 16044288. The ~925 KB after
|
||||
//! the last shrub class is real, non-zero data that is not accounted for yet.
|
||||
//! The chunk container's start is located by signature rather than read from
|
||||
//! the core index: the class table that precedes it carries no successor
|
||||
//! offset, so the boundary is found in `core_data` itself -- see
|
||||
//! [`crate::chunks::chunk_region_start`]. Every level has one: on the 24 levels
|
||||
//! with shrub it sits exactly two bytes after the last shrub body, and on the
|
||||
//! three levels without shrub it closes tie instead.
|
||||
//!
|
||||
//! Texture texels do **not** bound anything: they sit before moby, not after
|
||||
//! shrub. On level 0 the texel range is 3539328 to 5326208 while the last
|
||||
//! shrub class starts at 16044288.
|
||||
|
||||
use crate::chunks::chunk_region_start;
|
||||
use crate::level_core::{ArrayRange, LevelCoreHeader};
|
||||
|
||||
/// Bytes at the end of `core_data` that are identical across all 27 levels.
|
||||
@@ -85,12 +95,40 @@ pub fn first_geometry(index: &[u8], range: ArrayRange, stride: usize) -> Option<
|
||||
best
|
||||
}
|
||||
|
||||
/// The highest geometry offset in a class table, ignoring absent entries.
|
||||
///
|
||||
/// This is the *last* class in the region, which is where the search for the
|
||||
/// successor region starts. Starting there rather than at the region's head
|
||||
/// matters: the search is for the first chunk signature after this offset, so
|
||||
/// beginning it inside the region's own class bodies would risk matching one.
|
||||
pub fn last_geometry(index: &[u8], range: ArrayRange, stride: usize) -> Option<usize> {
|
||||
let count = range.count.max(0) as usize;
|
||||
let base = range.offset.max(0) as usize;
|
||||
let mut best: Option<usize> = None;
|
||||
for i in 0..count {
|
||||
let at = base + i * stride;
|
||||
let chunk = index.get(at..at + 4)?;
|
||||
let off = i32::from_le_bytes(chunk.try_into().expect("4 bytes"));
|
||||
if off > 0 {
|
||||
best = Some(best.map_or(off as usize, |b: usize| b.max(off as usize)));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Compute the region's bounds for one level.
|
||||
pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Region> {
|
||||
///
|
||||
/// `core` is the level's `core_data` lump. It is needed for one thing only: the
|
||||
/// class regions end at the chunk container, whose start is located by
|
||||
/// signature in `core` because the class table that precedes it carries no
|
||||
/// successor offset.
|
||||
pub fn core_layout(index: &[u8], core: &[u8], h: &LevelCoreHeader) -> Vec<Region> {
|
||||
let at = |v: i32| -> Option<usize> { (v > 0).then_some(v as usize) };
|
||||
let first_moby = first_geometry(index, h.moby_classes, 0x20);
|
||||
let first_tie = first_geometry(index, h.tie_classes, 0x20);
|
||||
let last_tie = last_geometry(index, h.tie_classes, 0x20);
|
||||
let first_shrub = first_geometry(index, h.shrub_classes, 0x30);
|
||||
let last_shrub = last_geometry(index, h.shrub_classes, 0x30);
|
||||
|
||||
// Each region ends where the next one begins. `chained` keeps `None`
|
||||
// propagating rather than silently closing a region against a gap.
|
||||
@@ -103,10 +141,20 @@ pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Re
|
||||
})
|
||||
}
|
||||
|
||||
// The chunk container follows the last of the shrub and tie classes.
|
||||
// Searching from the last *class* rather than from the region start keeps
|
||||
// the signature search out of the class bodies themselves.
|
||||
let container = match (last_shrub, last_tie) {
|
||||
(Some(shrub), _) => chunk_region_start(core, shrub),
|
||||
(None, Some(tie)) => chunk_region_start(core, tie),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
// The final region is bounded by the constant trailer rather than by
|
||||
// `core_data.len()`, which would let it absorb the trailer and whatever
|
||||
// tail follows the last named region.
|
||||
let tail_end = core_len
|
||||
let tail_end = core
|
||||
.len()
|
||||
.checked_sub(CORE_DATA_TRAILER_BYTES)
|
||||
.filter(|&e| e > 0);
|
||||
|
||||
@@ -115,9 +163,12 @@ pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Re
|
||||
chained("occlusion", at(h.occlusion), at(h.sky)),
|
||||
chained("sky", at(h.sky), at(h.collision)),
|
||||
chained("collision", at(h.collision), first_moby),
|
||||
chained("moby", first_moby, first_tie),
|
||||
chained("tie", first_tie, first_shrub),
|
||||
chained("shrub", first_shrub, tail_end),
|
||||
// A region with no successor region ends at the chunk container, which
|
||||
// is what follows the last class on every level.
|
||||
chained("moby", first_moby, first_tie.or(container)),
|
||||
chained("tie", first_tie, first_shrub.or(container)),
|
||||
chained("shrub", first_shrub, container),
|
||||
chained("chunks", container, tail_end),
|
||||
];
|
||||
|
||||
regions.into_iter().flatten().collect()
|
||||
@@ -148,7 +199,39 @@ mod tests {
|
||||
|
||||
fn layout(v: &[u8]) -> Vec<Region> {
|
||||
let h = crate::level_core::LevelCoreHeader::parse(v).unwrap();
|
||||
core_layout(v, &h, 1 << 24)
|
||||
core_layout(v, &core(v), &h)
|
||||
}
|
||||
|
||||
/// A zeroed lump, optionally carrying a real chunk-container signature just
|
||||
/// past the last class -- the layout locates the container in `core`, so a
|
||||
/// core of plain zeros locates nothing.
|
||||
fn core(v: &[u8]) -> Vec<u8> {
|
||||
let h = crate::level_core::LevelCoreHeader::parse(v).unwrap();
|
||||
let mut c = vec![0u8; 1 << 24];
|
||||
let last_class = [
|
||||
last_geometry(v, h.tie_classes, 0x20),
|
||||
last_geometry(v, h.shrub_classes, 0x30),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.max();
|
||||
if let Some(last) = last_class {
|
||||
plant_chunk(&mut c, last + 100);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The marker pair plus a chunk signature at `at`, as the disc carries it.
|
||||
fn plant_chunk(core: &mut [u8], at: usize) {
|
||||
core[at - 2..at].copy_from_slice(&crate::chunks::CHUNK_REGION_MARKER);
|
||||
core[at + 14] = 4;
|
||||
core[at + 15] = 0xff;
|
||||
for k in 0..4 {
|
||||
let word = (crate::chunks::CHUNK_TAG as u32) << 24;
|
||||
let at =
|
||||
at + crate::chunks::CHUNK_HEADER_BYTES + crate::chunks::CHUNK_GAP_BYTES + k * 4;
|
||||
core[at..at + 4].copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -170,7 +253,8 @@ mod tests {
|
||||
"collision",
|
||||
"moby",
|
||||
"tie",
|
||||
"shrub"
|
||||
"shrub",
|
||||
"chunks"
|
||||
]
|
||||
);
|
||||
assert_eq!(regions[0].start, 0);
|
||||
@@ -203,7 +287,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_region_ends_at_the_constant_trailer() {
|
||||
fn the_class_regions_end_at_the_chunk_container() {
|
||||
let core_len = 1 << 24;
|
||||
let v = index(
|
||||
100,
|
||||
@@ -212,29 +296,74 @@ mod tests {
|
||||
&[(200, &[1000]), (400, &[2000]), (600, &[3000])],
|
||||
);
|
||||
let regions = layout(&v);
|
||||
// The container sits 100 bytes past the last shrub class, which is
|
||||
// where the search for it starts.
|
||||
let container = 3000 + 100;
|
||||
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
|
||||
assert_eq!(shrub.start, 3000);
|
||||
assert_eq!(shrub.end, Some(container));
|
||||
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
|
||||
assert_eq!(chunks.start, container);
|
||||
// Not core_data.len(): the trailer is constant across levels and does
|
||||
// not belong to any region.
|
||||
assert_eq!(shrub.end, Some(core_len - CORE_DATA_TRAILER_BYTES));
|
||||
assert!(shrub.end.unwrap() < core_len);
|
||||
assert_eq!(chunks.end, Some(core_len - CORE_DATA_TRAILER_BYTES));
|
||||
assert!(chunks.end.unwrap() < core_len);
|
||||
// Tie still ends at shrub, not at the container.
|
||||
let tie = regions.iter().find(|r| r.name == "tie").unwrap();
|
||||
assert_eq!(tie.end, Some(3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lump_shorter_than_the_trailer_leaves_the_end_unresolved() {
|
||||
let mut v = index(
|
||||
fn tie_ends_at_the_container_when_there_is_no_shrub() {
|
||||
// Third class table absent: tie is the last class region, so it is tie
|
||||
// the container closes.
|
||||
let v = index(100, 200, 300, &[(200, &[1000]), (400, &[2000])]);
|
||||
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
|
||||
let core = core(&v);
|
||||
let container = 2000 + 100;
|
||||
let regions = core_layout(&v, &core, &h);
|
||||
let tie = regions.iter().find(|r| r.name == "tie").unwrap();
|
||||
assert_eq!(tie.end, Some(container));
|
||||
assert!(regions.iter().all(|r| r.name != "shrub"));
|
||||
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
|
||||
assert_eq!(chunks.start, container);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocated_container_leaves_the_class_end_unresolved() {
|
||||
// A core of plain zeros carries no signature, so shrub cannot be
|
||||
// closed -- the end stays visible rather than clamped to the lump's
|
||||
// tail, and the chunk region does not exist at all.
|
||||
let v = index(
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
&[(200, &[1000]), (400, &[2000]), (600, &[3000])],
|
||||
);
|
||||
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
|
||||
let regions = core_layout(&v, &h, CORE_DATA_TRAILER_BYTES - 1);
|
||||
let regions = core_layout(&v, &[0u8; 4 << 20], &h);
|
||||
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
|
||||
assert_eq!(shrub.end, None, "must not clamp a backwards bound");
|
||||
let _ = &mut v;
|
||||
assert_eq!(shrub.end, None);
|
||||
assert!(regions.iter().all(|r| r.name != "chunks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lump_shorter_than_the_trailer_leaves_the_end_unresolved() {
|
||||
let v = index(
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
&[(200, &[1000]), (400, &[2000]), (600, &[3000])],
|
||||
);
|
||||
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
|
||||
// Shorter than the trailer, so the container starts but cannot end.
|
||||
let mut core = vec![0u8; CORE_DATA_TRAILER_BYTES - 1];
|
||||
plant_chunk(&mut core, 3000 + 100);
|
||||
let regions = core_layout(&v, &core, &h);
|
||||
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
|
||||
assert_eq!(shrub.end, Some(3100));
|
||||
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
|
||||
assert_eq!(chunks.end, None, "must not clamp a backwards bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+567
-106
@@ -1,4 +1,4 @@
|
||||
//! Shrub geometry: the class header.
|
||||
//! Shrub geometry: the class header, the normal palette, and the billboard.
|
||||
//!
|
||||
//! Spec: `docs/plan/level-assets.md`.
|
||||
//!
|
||||
@@ -8,113 +8,357 @@
|
||||
//! rule `vertex_offset + 16 * vertex_count == next_part`, which holds for
|
||||
//! 43,467 of 43,467 moby parts, holds for **zero** of them.
|
||||
//!
|
||||
//! What the shrub header *does* have is two fields that self-identify exactly,
|
||||
//! on all 358 spans:
|
||||
//! The class blob is header, palette, optional billboard, in that order. The
|
||||
//! reference for the shape is R&C1's shrub class (documented in
|
||||
//! `vendor/rerac/docs/formats/shrub_sky_rac1.md`, whose offsets this header
|
||||
//! follows: `o_class` at 0x24, a count at 0x28, the data offset at 0x2c), but
|
||||
//! every field below is either **measured on all 358 spans** or marked as not
|
||||
//! established. Where a field is only parallel to R&C1 and not independently
|
||||
//! verified here, the doc comment says so.
|
||||
//!
|
||||
//! What self-identifies exactly, on all 358 spans:
|
||||
//!
|
||||
//! - `+0x24` equals the owning class entry's own `o_class`.
|
||||
//! - `+0x2c` is a data offset that lands inside the span.
|
||||
//! - `+0x1c` (billboard offset) is non-zero on exactly the 25 classes whose
|
||||
//! core-index entry carries billboard texture fields, and zero on the other
|
||||
//! 333 -- two independent places, one answer.
|
||||
//!
|
||||
//! Those two are what this parses. The body past the data offset is not
|
||||
//! decoded: the run of bytes there does not tile at any stride that divides the
|
||||
//! tail consistently with the count at `+0x28`.
|
||||
//!
|
||||
//! Note the class entry size differs here too: shrub entries are 0x30 bytes,
|
||||
//! not 0x20, because they carry billboard texture fields the other two tables
|
||||
//! do not have.
|
||||
//! The class entry size differs here too: shrub entries are 0x30 bytes, not
|
||||
//! 0x20, because they carry billboard texture fields the other two tables do
|
||||
//! not have.
|
||||
|
||||
use crate::buf::{Buf, Error, ErrorKind, Result};
|
||||
|
||||
/// The measured size of a shrub class entry.
|
||||
pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30;
|
||||
|
||||
/// Normals in the palette. Not a maximum: the body's zero padding follows the
|
||||
/// twenty-fourth entry, so a body of 26, 28 or 30 records holds 24 normals and
|
||||
/// 2, 4 or 6 zero records.
|
||||
pub const SHRUB_NORMAL_COUNT: usize = 24;
|
||||
|
||||
/// One palette entry: a unit normal as three `s16`, then a zero `s16`.
|
||||
///
|
||||
/// The three components are a fraction of 32767 -- entry `i` of every body
|
||||
/// satisfies `x^2 + y^2 + z^2` within rounding of `32767^2` on all 358 spans.
|
||||
/// The fourth word is zero on all 9,600 records on the disc, which is what
|
||||
/// makes the record 8 bytes rather than 6.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShrubNormal {
|
||||
pub x: i16,
|
||||
pub y: i16,
|
||||
pub z: i16,
|
||||
}
|
||||
|
||||
impl ShrubNormal {
|
||||
/// Parse one 8-byte palette record.
|
||||
pub fn parse(bytes: &[u8]) -> Result<ShrubNormal> {
|
||||
let mut b = Buf::new(bytes);
|
||||
let x = b.i16("shrub normal x")?;
|
||||
let y = b.i16("shrub normal y")?;
|
||||
let z = b.i16("shrub normal z")?;
|
||||
let pad = b.i16("shrub normal pad")?;
|
||||
if pad != 0 {
|
||||
return Err(Error {
|
||||
offset: 6,
|
||||
context: "shrub normal pad",
|
||||
kind: ErrorKind::Invalid("shrub normal pad is not zero"),
|
||||
});
|
||||
}
|
||||
Ok(ShrubNormal { x, y, z })
|
||||
}
|
||||
|
||||
/// Whether the entry is a unit normal, within the format's quantisation.
|
||||
///
|
||||
/// The palette stores a unit vector scaled by 32768, so an axis-aligned
|
||||
/// normal quantises to `(32768, 0, 0)` -- stored as the `s16` `-32768`,
|
||||
/// because the positive axis clamps instead. The squared length therefore
|
||||
/// lands within `32768^2 - 32767^2 = 65535` of `32767^2`, and that bound
|
||||
/// is tight: the largest deviation on the disc is exactly 65,535.
|
||||
///
|
||||
/// The margin is what makes this a test rather than a tautology. A random
|
||||
/// `s16` triple has a squared length spread about a *billion* either side
|
||||
/// of `32767^2`, so roughly one entry in fifteen thousand would pass by
|
||||
/// chance; 8,592 of 8,592 real entries pass.
|
||||
pub fn is_unit(&self) -> bool {
|
||||
let n = (self.x as i64).pow(2) + (self.y as i64).pow(2) + (self.z as i64).pow(2);
|
||||
(n - 32767i64.pow(2)).abs() <= 65_535
|
||||
}
|
||||
|
||||
/// The normal as a unit vector in `[0, 1]`-ish float terms.
|
||||
pub fn unit(self) -> [f32; 3] {
|
||||
[
|
||||
self.x as f32 / 32767.0,
|
||||
self.y as f32 / 32767.0,
|
||||
self.z as f32 / 32767.0,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded part of a shrub class header.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ShrubHeader {
|
||||
/// Four floats at `+0x00..+0x10`. All finite on all 358 spans, but they do
|
||||
/// not behave like a `min.xyz / max.xyz` bounding box -- that ordering only
|
||||
/// holds on 146 of 358. Not named because it is not established.
|
||||
pub bounds: [f32; 4],
|
||||
/// A positive float at `+0x10`, 3.0 to 20.0 across the disc.
|
||||
/// Four floats at `+0x00..+0x10`: a bounding sphere read as
|
||||
/// centre (x, y, z) and radius (w), following R&C1's header at the same
|
||||
/// offsets. What is measured here rather than assumed: all four are
|
||||
/// finite, the radius is positive on all 358 spans (1.72..41.57), and the
|
||||
/// centres sit near the class's own origin (-18..15, -23..23, -16..20).
|
||||
/// The earlier reading of this field as a `min.xyz / max.xyz` box was
|
||||
/// wrong -- that ordering holds on only 146 of 358 spans -- because it was
|
||||
/// the wrong shape, not because the bytes were.
|
||||
pub sphere: [f32; 4],
|
||||
/// A positive float at `+0x10`, 3.0 to 20.0 across the disc. R&C1's
|
||||
/// parallel at this offset is `mip_distance`; not independently
|
||||
/// established.
|
||||
pub size: f32,
|
||||
/// A float at `+0x14`. Exactly 0.0 on 342 of the disc's 358 spans; the
|
||||
/// other 16 hold the bit pattern `04000000`, a denormal of about 2.8e-45.
|
||||
/// A `u16` at `+0x14`: zero on 342 spans, 4 on the other 16. R&C1's
|
||||
/// `mode_bits` is at the same offset. Not established.
|
||||
pub mode_bits: u16,
|
||||
/// A `u16` at `+0x16`, zero on all 358 spans. R&C1's field here is
|
||||
/// `instance_count`, runtime scratch that retail files leave zero.
|
||||
///
|
||||
/// The Emotion Engine has no denormals, so hardware reads all 358 of these
|
||||
/// as zero. That is the only reading the game itself ever sees, and it is
|
||||
/// why this is safe to treat as a flag rather than a magnitude -- see
|
||||
/// `AGENTS.md` on reproducing the FPU model rather than correcting it.
|
||||
pub reserved: f32,
|
||||
/// On the 16 spans where `mode_bits` is 4 the whole word at `+0x14` is an
|
||||
/// `f32` denormal, so the Emotion Engine -- which has no denormals -- reads
|
||||
/// zero for the pair either way. That is the sharpest available statement
|
||||
/// of what the hardware does with this field.
|
||||
pub scratch: u16,
|
||||
/// A `u32` at `+0x18`, zero on all 358 spans. R&C1's `instances_pointer`
|
||||
/// is runtime scratch at the same offset.
|
||||
pub reserved: u32,
|
||||
/// Offset of the [`ShrubBillboard`] record from the start of the span.
|
||||
/// Zero means no billboard; a positive value lands inside the span on all
|
||||
/// 25 spans that have one.
|
||||
///
|
||||
/// This is the strongest self-identification in the header after
|
||||
/// `o_class`: it is non-zero on exactly the classes whose core-index
|
||||
/// entry carries billboard texture fields (25 of 358 each way), and the
|
||||
/// two answers are produced by different bytes in different files.
|
||||
pub billboard_offset: u32,
|
||||
/// A positive `f32` at `+0x20` (0.0044..1.8 on the disc). R&C1's
|
||||
/// `scale` is the position dequantisation factor at the same offset.
|
||||
/// Not independently established.
|
||||
pub scale: f32,
|
||||
/// The class this geometry belongs to; equals the class entry's
|
||||
/// `o_class` on all 358 spans on the disc.
|
||||
pub o_class: u32,
|
||||
/// A count. Meaning not established.
|
||||
/// A count. It is the **packet count** -- the length of the table at
|
||||
/// `+0x40` -- which the packet tiling proves (see [`Shrub::packets`]).
|
||||
/// Ranges 1..55 on the disc; unrelated to the palette's length.
|
||||
pub count: u32,
|
||||
/// Offset of the geometry data from the start of the span. Lands inside
|
||||
/// the span on all 358 spans on the disc.
|
||||
pub data_offset: u32,
|
||||
}
|
||||
|
||||
/// Bytes per body record.
|
||||
/// The billboard record at `billboard_offset`, 0x40 bytes.
|
||||
///
|
||||
/// Each record is 6 varying bytes followed by 2 constant ones. Found by
|
||||
/// grouping sibling spans and byte-diffing them rather than by stride-fitting,
|
||||
/// which failed three times: the varying payload is **not** sized by the
|
||||
/// `count` field, and was a constant 144 bytes per group whether `count` was
|
||||
/// 1, 2, 3, 5 or 6.
|
||||
pub const SHRUB_BODY_RECORD_BYTES: usize = 8;
|
||||
/// The register block matches R&C1's layout field for field -- TEX1 at `+0x10`
|
||||
/// with `K` and `MMIN`, TEX0 at `+0x20`, MIPTBP1_1 at `+0x30`, each carrying
|
||||
/// its own register address word (0x14, 0x06, 0x34) -- and the three floats
|
||||
/// in front of them are the one part that does not carry over: they are far
|
||||
/// too large and too varied to be R&C1's `width`/`height`/`z_ofs`, and they
|
||||
/// repeat byte-identically across levels, which dimensions would not. They
|
||||
/// are handed back unnamed rather than guessed at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ShrubBillboard {
|
||||
/// Fade distance at `+0x00` (5..1000 on the disc). Following R&C1, the
|
||||
/// loader takes `trunc(fade) & 0xff`, which makes 512 read as 0 --
|
||||
/// billboard only at any distance.
|
||||
pub fade_distance: f32,
|
||||
/// Three floats at `+0x04..+0x10`. Meaning not established.
|
||||
pub unknown: [f32; 3],
|
||||
/// `TEX1_1` at `+0x10`: the signed LOD bias K in 1/16 units, retail
|
||||
/// -233..-168 on the disc.
|
||||
pub k: i16,
|
||||
/// `TEX1_1` minification filter, 4 on every disc billboard.
|
||||
pub mmin: u32,
|
||||
/// `TEX0_1.data_lo` at `+0x20`: a small index (1..4 on the disc). In R&C1
|
||||
/// this is an index into the class's texture list.
|
||||
pub tex0: u32,
|
||||
/// The GIF register address words carried behind the three registers,
|
||||
/// 0x14 / 0x06 / 0x34 on every disc billboard. Exposed rather than
|
||||
/// asserted on: they corroborate the layout, but a billboard that carries
|
||||
/// a different register set is still a billboard.
|
||||
pub tex1_addr: u32,
|
||||
/// See [`ShrubBillboard::tex1_addr`].
|
||||
pub tex0_addr: u32,
|
||||
/// See [`ShrubBillboard::tex1_addr`].
|
||||
pub miptbp_addr: u32,
|
||||
}
|
||||
|
||||
/// The two constant bytes closing each body record.
|
||||
pub const SHRUB_RECORD_TAIL: [u8; 2] = [0, 0];
|
||||
|
||||
/// A shrub span: its header plus the bytes the header points at.
|
||||
/// A shrub span: its header, palette and optional billboard.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Shrub<'a> {
|
||||
pub header: ShrubHeader,
|
||||
/// The whole span, starting at `+0x24` with the class number.
|
||||
pub raw: &'a [u8],
|
||||
/// The bytes at `data_offset`, running to `span_end`. Not yet decoded.
|
||||
/// The bytes at `data_offset`, running to `span_end`. This is the palette
|
||||
/// plus its zero padding.
|
||||
pub data: &'a [u8],
|
||||
/// Whether `span_end` came from the next class rather than from the end of
|
||||
/// the lump. False for the last class in each level, whose span contains
|
||||
/// whatever follows shrub.
|
||||
/// Whether `span_end` came from the next class or the located chunk
|
||||
/// container rather than from the end of the lump. False only for a span
|
||||
/// bounded by a caller that could not locate either.
|
||||
pub span_end_is_a_successor: bool,
|
||||
}
|
||||
|
||||
impl<'a> Shrub<'a> {
|
||||
/// The body's 8-byte records.
|
||||
/// One mesh packet: where it sits in the span and how long it is.
|
||||
///
|
||||
/// The payload of each record is six bytes whose encoding is **not**
|
||||
/// established. It is not half-floats -- decoding as a `half3` gives
|
||||
/// values like 245.25 and -168.375 -- and a VIF reading of it was
|
||||
/// retracted after a control put it on the random baseline. Handed back
|
||||
/// raw rather than guessed at.
|
||||
/// The offsets are relative to the start of the span, like every other
|
||||
/// pointer in a class blob.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShrubPacket {
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
impl<'a> Shrub<'a> {
|
||||
/// The palette's 8-byte records, raw.
|
||||
pub fn body_records(&self) -> impl Iterator<Item = &'a [u8]> {
|
||||
self.data.chunks_exact(SHRUB_BODY_RECORD_BYTES)
|
||||
self.data.chunks_exact(8)
|
||||
}
|
||||
|
||||
/// How many whole records the body holds.
|
||||
pub fn body_record_count(&self) -> usize {
|
||||
self.data.len() / SHRUB_BODY_RECORD_BYTES
|
||||
self.data.len() / 8
|
||||
}
|
||||
|
||||
/// Whether every whole record ends in the constant pair.
|
||||
/// The mesh packets, as the packet table at `+0x40` names them.
|
||||
///
|
||||
/// This is how a class's body is delimited: on all 24 levels that have
|
||||
/// shrub geometry, the last shrub class's body ends exactly where its
|
||||
/// trailing `00 00` pair stops, always on a record boundary.
|
||||
/// The table is `count` entries of `(offset, size)`, both relative to the
|
||||
/// span start. What makes it a table rather than a guess is that the
|
||||
/// entries **tile the blob**: the first packet starts at
|
||||
/// `align16(0x40 + count * 8)` -- the table, rounded up to 16 -- every
|
||||
/// entry is exactly `size` past the previous one, and the last ends at the
|
||||
/// palette, or exactly 0x40 short of it on the spans that carry a
|
||||
/// billboard record in between. That holds on **358 of 358** spans on the
|
||||
/// disc, and it is what pins the field meanings: `count` is the packet
|
||||
/// count, and `data_offset` is the palette's offset.
|
||||
///
|
||||
/// Returns `None` when the entries do not tile, rather than handing back
|
||||
/// a partial list: a table that stops making sense means this is not
|
||||
/// laid out the way this thinks it is.
|
||||
pub fn packets(&self) -> Option<Vec<ShrubPacket>> {
|
||||
let count = self.header.count as usize;
|
||||
// A count that cannot be read out of the span is not a count.
|
||||
if self.raw.len() < 0x40 + count.saturating_mul(8) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(count);
|
||||
let mut cursor = (0x40 + count.saturating_mul(8) + 0xf) & !0xf;
|
||||
for i in 0..count {
|
||||
let at = 0x40 + i * 8;
|
||||
let entry = self.raw.get(at..at + 8)?;
|
||||
let offset = u32::from_le_bytes(entry[..4].try_into().ok()?) as usize;
|
||||
let size = u32::from_le_bytes(entry[4..].try_into().ok()?) as usize;
|
||||
if offset != cursor {
|
||||
return None;
|
||||
}
|
||||
cursor += size;
|
||||
out.push(ShrubPacket { offset, size });
|
||||
}
|
||||
// The packets end where the palette begins, leaving room for the
|
||||
// billboard record when the class has one.
|
||||
let palette = self.header.data_offset as usize;
|
||||
let expected_end = if self.header.billboard_offset > 0 {
|
||||
palette.checked_sub(0x40)?
|
||||
} else {
|
||||
palette
|
||||
};
|
||||
(cursor == expected_end).then_some(out)
|
||||
}
|
||||
|
||||
/// The palette: the first [`SHRUB_NORMAL_COUNT`] records, parsed.
|
||||
///
|
||||
/// Returns fewer only if the body is shorter than the palette, which does
|
||||
/// not happen on the disc -- every one of the 358 spans carries all 24.
|
||||
pub fn normals(&self) -> impl Iterator<Item = ShrubNormal> + 'a {
|
||||
self.data
|
||||
.chunks_exact(8)
|
||||
.take(SHRUB_NORMAL_COUNT)
|
||||
.filter_map(move |r| ShrubNormal::parse(r).ok())
|
||||
}
|
||||
|
||||
/// Records past the palette. Zero on every disc span: the palette is
|
||||
/// padded out with zero records rather than with live normals.
|
||||
pub fn padding_record_count(&self) -> usize {
|
||||
self.body_record_count().saturating_sub(SHRUB_NORMAL_COUNT)
|
||||
}
|
||||
|
||||
/// Whether every whole record's trailing pair is zero.
|
||||
///
|
||||
/// The palette's pad word and the padding records are both zero, so a
|
||||
/// body that satisfies this is whole records; it is how a class's body is
|
||||
/// delimited, and it holds on all 358 disc spans now that each is bounded
|
||||
/// by its successor.
|
||||
pub fn body_is_well_formed(&self) -> bool {
|
||||
let records: Vec<&[u8]> = self.body_records().collect();
|
||||
!records.is_empty() && records.iter().all(|r| r[6..8] == SHRUB_RECORD_TAIL)
|
||||
!records.is_empty() && records.iter().all(|r| r[6..8] == [0, 0])
|
||||
}
|
||||
|
||||
/// The billboard record, when the header names one.
|
||||
///
|
||||
/// The offset is checked against `span_end` by
|
||||
/// [`ShrubHeader::region`] rather than trusted: a non-zero offset that
|
||||
/// runs past the span is a mis-set field, not a billboard.
|
||||
pub fn billboard(&self) -> Result<Option<ShrubBillboard>> {
|
||||
if self.header.billboard_offset == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let at = self.header.billboard_offset as usize;
|
||||
// `raw` runs from the span start, so the record sits at raw[at..].
|
||||
let bytes = self.raw.get(at..at + 0x40).ok_or(Error {
|
||||
offset: at,
|
||||
context: "shrub billboard",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: 0x40,
|
||||
available: self.raw.len().saturating_sub(at),
|
||||
},
|
||||
})?;
|
||||
let mut b = Buf::new(bytes);
|
||||
let fade_distance = b.f32("shrub billboard fade")?;
|
||||
let unknown = [
|
||||
b.f32("shrub billboard unknown 0")?,
|
||||
b.f32("shrub billboard unknown 1")?,
|
||||
b.f32("shrub billboard unknown 2")?,
|
||||
];
|
||||
// TEX1_1: K, a zero high half, then MMIN, then the register address
|
||||
// word and a zero pad -- a 16-byte GifAdData16 per register.
|
||||
let k = b.i16("shrub billboard K")?;
|
||||
b.skip(2, "shrub billboard K high")?;
|
||||
let mmin = b.u32("shrub billboard mmin")?;
|
||||
let tex1_addr = b.u32("shrub billboard tex1 address")?;
|
||||
b.skip(4, "shrub billboard tex1 pad")?;
|
||||
let tex0 = b.u32("shrub billboard tex0 index")?;
|
||||
b.skip(4, "shrub billboard tex0 data hi")?;
|
||||
let tex0_addr = b.u32("shrub billboard tex0 address")?;
|
||||
// MIPTBP1_1: two zero data words, then the register address word and a
|
||||
// pad. The mip base pointers are filled in at level load, as they are
|
||||
// for every ad-gif in this format.
|
||||
b.skip(4, "shrub billboard tex0 pad")?;
|
||||
b.skip(8, "shrub billboard miptbp data")?;
|
||||
let miptbp_addr = b.u32("shrub billboard miptbp address")?;
|
||||
Ok(Some(ShrubBillboard {
|
||||
fade_distance,
|
||||
unknown,
|
||||
k,
|
||||
mmin,
|
||||
tex0,
|
||||
tex1_addr,
|
||||
tex0_addr,
|
||||
miptbp_addr,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShrubHeader {
|
||||
/// Parse a shrub span starting at `offset` within `core_data`.
|
||||
/// Parse a shrub span starting at `offset` within `core`.
|
||||
///
|
||||
/// `o_class` is passed in so the self-identifying field can be checked
|
||||
/// rather than merely reported: a span whose `+0x24` disagrees with its
|
||||
/// own class entry is a different thing entirely, and quietly returning
|
||||
/// the mismatched value would hide that.
|
||||
///
|
||||
/// `span_end` bounds the class span within `core`. It matters: without it
|
||||
/// `data` runs to the end of the lump and swallows whatever follows, which
|
||||
/// is how a per-class check ends up measuring its neighbours.
|
||||
@@ -137,41 +381,75 @@ impl ShrubHeader {
|
||||
},
|
||||
})?;
|
||||
let mut fb = Buf::new(&head[..16]);
|
||||
let bounds = [
|
||||
fb.f32("shrub bounds 0")?,
|
||||
fb.f32("shrub bounds 1")?,
|
||||
fb.f32("shrub bounds 2")?,
|
||||
fb.f32("shrub bounds 3")?,
|
||||
let sphere = [
|
||||
fb.f32("shrub sphere x")?,
|
||||
fb.f32("shrub sphere y")?,
|
||||
fb.f32("shrub sphere z")?,
|
||||
fb.f32("shrub sphere radius")?,
|
||||
];
|
||||
let mut fs = Buf::new(&head[0x10..0x18]);
|
||||
let size = fs.f32("shrub size")?;
|
||||
let reserved = fs.f32("shrub reserved")?;
|
||||
if !bounds.iter().all(|v| v.is_finite()) || !size.is_finite() || !reserved.is_finite() {
|
||||
let mode_bits = fs.u16("shrub mode bits")?;
|
||||
let scratch = fs.u16("shrub scratch")?;
|
||||
if !sphere.iter().all(|v| v.is_finite()) || !size.is_finite() {
|
||||
return Err(Error {
|
||||
offset: start,
|
||||
context: "shrub header holds a non-finite float",
|
||||
kind: ErrorKind::Invalid("shrub header float"),
|
||||
});
|
||||
}
|
||||
// Zero, or the denormal the PS2 would flush to zero anyway. Anything
|
||||
// else means the header is not laid out the way this thinks it is.
|
||||
if reserved != 0.0 && !reserved.is_subnormal() {
|
||||
// A radius is a radius: non-positive means the header is not laid out
|
||||
// the way this thinks it is.
|
||||
if sphere[3] <= 0.0 {
|
||||
return Err(Error {
|
||||
offset: start + 0x14,
|
||||
context: "shrub reserved word is neither zero nor denormal",
|
||||
kind: ErrorKind::Invalid("shrub reserved"),
|
||||
offset: start + 0x0c,
|
||||
context: "shrub sphere radius",
|
||||
kind: ErrorKind::Invalid("shrub sphere radius is not positive"),
|
||||
});
|
||||
}
|
||||
// One invariant across all 358 spans: the scratch word at +0x16 is
|
||||
// zero everywhere. A non-zero value means this is not a shrub header.
|
||||
// `mode_bits` at +0x14 is *not* asserted -- it is 4 on 16 of the 358
|
||||
// spans -- so the field is reported rather than validated.
|
||||
if scratch != 0 {
|
||||
return Err(Error {
|
||||
offset: start + 0x16,
|
||||
context: "shrub scratch",
|
||||
kind: ErrorKind::Invalid("shrub scratch is not zero"),
|
||||
});
|
||||
}
|
||||
let reserved = u32::from_le_bytes(head[0x18..0x1c].try_into().expect("4 bytes at +0x18"));
|
||||
if reserved != 0 {
|
||||
return Err(Error {
|
||||
offset: start + 0x18,
|
||||
context: "shrub reserved word",
|
||||
kind: ErrorKind::Invalid("shrub reserved word is not zero"),
|
||||
});
|
||||
}
|
||||
|
||||
let mut b = Buf::new(&head[0x24..]);
|
||||
let span_class = b.u32("shrub o_class")?;
|
||||
let mut b = Buf::new(&head[0x1c..]);
|
||||
let billboard_offset = b.u32("shrub billboard offset")?;
|
||||
let scale = b.f32("shrub scale")?;
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err(Error {
|
||||
offset: start + 0x20,
|
||||
context: "shrub scale",
|
||||
kind: ErrorKind::Invalid("shrub scale is not positive"),
|
||||
});
|
||||
}
|
||||
let mut bc = Buf::new(&head[0x24..]);
|
||||
let span_class = bc.u32("shrub o_class")?;
|
||||
let header = ShrubHeader {
|
||||
bounds: [0.0; 4],
|
||||
sphere: [0.0; 4],
|
||||
size: 0.0,
|
||||
reserved: 0.0,
|
||||
mode_bits: 0,
|
||||
scratch,
|
||||
reserved,
|
||||
billboard_offset,
|
||||
scale,
|
||||
o_class: span_class,
|
||||
count: b.u32("shrub count")?,
|
||||
data_offset: b.u32("shrub data offset")?,
|
||||
count: bc.u32("shrub count")?,
|
||||
data_offset: bc.u32("shrub data offset")?,
|
||||
};
|
||||
|
||||
if header.o_class != o_class.max(0) as u32 {
|
||||
@@ -182,8 +460,17 @@ impl ShrubHeader {
|
||||
});
|
||||
}
|
||||
|
||||
let at = header.data_offset as usize;
|
||||
// A billboard that is not inside the span is a mis-set offset.
|
||||
let stop = span_end.clamp(start, core.len());
|
||||
if billboard_offset > 0 && start + billboard_offset as usize + 0x40 > stop {
|
||||
return Err(Error {
|
||||
offset: start + 0x1c,
|
||||
context: "shrub billboard offset",
|
||||
kind: ErrorKind::Invalid("shrub billboard offset runs past the span"),
|
||||
});
|
||||
}
|
||||
|
||||
let at = header.data_offset as usize;
|
||||
let data = core.get(start + at..stop).ok_or(Error {
|
||||
offset: start + at,
|
||||
context: "shrub data offset runs past the lump",
|
||||
@@ -195,9 +482,13 @@ impl ShrubHeader {
|
||||
Ok(Some(Shrub {
|
||||
span_end_is_a_successor: true,
|
||||
header: ShrubHeader {
|
||||
bounds,
|
||||
sphere,
|
||||
size,
|
||||
mode_bits,
|
||||
scratch,
|
||||
reserved,
|
||||
billboard_offset,
|
||||
scale,
|
||||
o_class: header.o_class,
|
||||
count: header.count,
|
||||
data_offset: header.data_offset,
|
||||
@@ -216,16 +507,41 @@ mod tests {
|
||||
/// span cannot start there.
|
||||
const BASE: usize = 0x10;
|
||||
|
||||
/// A span whose `+0x24` names class 1072 and whose data starts at 0x30.
|
||||
fn span() -> Vec<u8> {
|
||||
let mut v = vec![0u8; BASE + 0x40];
|
||||
/// A unit direction per palette slot: a spiral over the sphere, so every
|
||||
/// entry is a different unit vector rather than a repeated one.
|
||||
fn fixture_normal(i: usize) -> [f32; 3] {
|
||||
let t = i as f32 / SHRUB_NORMAL_COUNT as f32 * std::f32::consts::TAU;
|
||||
let z = (i % 5) as f32 / 2.0 - 1.0;
|
||||
let r = (1.0 - z * z).max(0.0).sqrt();
|
||||
[r * t.cos(), r * t.sin(), z]
|
||||
}
|
||||
|
||||
/// A span naming class 1072, data at 0x30, a positive radius and scale,
|
||||
/// and a 24-entry palette of unit normals. `tail` extra bytes follow the
|
||||
/// palette inside the span: zero ones read as padding records.
|
||||
fn span_with_tail(tail: usize) -> Vec<u8> {
|
||||
let mut v = vec![0u8; BASE + 0x30 + SHRUB_NORMAL_COUNT * 8 + tail];
|
||||
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
|
||||
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
|
||||
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
|
||||
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
|
||||
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
|
||||
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
|
||||
v[BASE + 0x30..].fill(0xab);
|
||||
for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
|
||||
let at = BASE + 0x30 + i * 8;
|
||||
for (c, &v_c) in n.iter().enumerate() {
|
||||
let q = (v_c * 32767.0).round() as i16;
|
||||
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A span with two zero padding records after the palette.
|
||||
fn span() -> Vec<u8> {
|
||||
span_with_tail(16)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_self_identifying_header() {
|
||||
let c = span();
|
||||
@@ -235,7 +551,168 @@ mod tests {
|
||||
assert_eq!(s.header.o_class, 1072);
|
||||
assert_eq!(s.header.count, 5);
|
||||
assert_eq!(s.header.data_offset, 0x30);
|
||||
assert!(s.data.iter().all(|&b| b == 0xab));
|
||||
assert_eq!(s.header.billboard_offset, 0);
|
||||
assert_eq!(s.header.scale, 0.25);
|
||||
assert_eq!(s.header.sphere[3], 12.5);
|
||||
assert!(s.billboard().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_is_24_unit_normals_plus_zero_padding() {
|
||||
let c = span();
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
let normals: Vec<ShrubNormal> = s.normals().collect();
|
||||
assert_eq!(normals.len(), SHRUB_NORMAL_COUNT);
|
||||
assert!(normals.iter().all(|n| n.is_unit()), "palette is not unit");
|
||||
// The fixture's two trailing zero records are padding, not normals.
|
||||
assert_eq!(s.padding_record_count(), 2);
|
||||
assert_eq!(s.body_record_count(), SHRUB_NORMAL_COUNT + 2);
|
||||
assert!(s.body_is_well_formed());
|
||||
// The palette repeats no direction, and the padding is not in it.
|
||||
assert!(!s.normals().any(|n| n == ShrubNormal { x: 0, y: 0, z: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_of_zeros_is_not_a_palette_of_normals() {
|
||||
let mut v = vec![0u8; BASE + 0x40];
|
||||
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
|
||||
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
|
||||
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
|
||||
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
|
||||
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
|
||||
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
|
||||
let s = ShrubHeader::region(&v, BASE as i32, 1072, v.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
// Every record is whole and zero-tailed, but no entry is a normal.
|
||||
assert!(s.body_is_well_formed());
|
||||
assert!(!s.normals().any(|n| n.is_unit()));
|
||||
}
|
||||
|
||||
/// A span laid out the way a disc blob is: header, packet table, packets,
|
||||
/// then the palette at the end.
|
||||
fn span_with_packets(palette_at: usize) -> Vec<u8> {
|
||||
let mut v = vec![0u8; BASE + palette_at + SHRUB_NORMAL_COUNT * 8];
|
||||
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
|
||||
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
|
||||
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
|
||||
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
|
||||
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&(palette_at as u32).to_le_bytes());
|
||||
for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
|
||||
let at = BASE + palette_at + i * 8;
|
||||
for (c, v_c) in n.iter().enumerate() {
|
||||
let q = (v_c * 32767.0).round() as i16;
|
||||
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_packets_tile_the_blob() {
|
||||
// Three packets tiling from align16(0x40 + 3*8) = 0x60 to the palette.
|
||||
let palette_at = 0x300usize;
|
||||
let mut c = span_with_packets(palette_at);
|
||||
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&3u32.to_le_bytes());
|
||||
let entries = [
|
||||
(0x60usize, 0x40usize),
|
||||
(0xa0, 0x30),
|
||||
(0xd0, palette_at - 0xd0),
|
||||
];
|
||||
for (i, (o, sz)) in entries.iter().enumerate() {
|
||||
let at = BASE + 0x40 + i * 8;
|
||||
c[at..at + 4].copy_from_slice(&(*o as u32).to_le_bytes());
|
||||
c[at + 4..at + 8].copy_from_slice(&(*sz as u32).to_le_bytes());
|
||||
}
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
let packets = s.packets().expect("the entries tile");
|
||||
assert_eq!(packets.len(), 3);
|
||||
assert_eq!(packets[0].size, 0x40);
|
||||
assert_eq!(packets[2].size, palette_at - 0xd0);
|
||||
// The palette is untouched by the packets sitting before it.
|
||||
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
|
||||
assert!(s.normals().all(|n| n.is_unit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_billboard_record_sits_between_the_packets_and_the_palette() {
|
||||
// With a billboard, the packets end 0x40 short of the palette.
|
||||
let palette_at = 0x300usize;
|
||||
let mut c = span_with_packets(palette_at);
|
||||
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&1u32.to_le_bytes());
|
||||
// One entry: the table ends at 0x48, aligned up to 0x50.
|
||||
let size = palette_at - 0x40 - 0x50;
|
||||
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x50u32.to_le_bytes());
|
||||
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&(size as u32).to_le_bytes());
|
||||
// A billboard offset inside the span, in its last 0x40 bytes.
|
||||
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((palette_at - 0x40) as u32).to_le_bytes());
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
let packets = s.packets().expect("the entry stops at the billboard");
|
||||
assert_eq!(packets.len(), 1);
|
||||
assert_eq!(packets[0].size, palette_at - 0x40 - 0x50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_table_that_does_not_tile_is_rejected() {
|
||||
let palette_at = 0x300usize;
|
||||
let mut c = span_with_packets(palette_at);
|
||||
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&2u32.to_le_bytes());
|
||||
// The second entry starts mid-packet, not where the first ended.
|
||||
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x60u32.to_le_bytes());
|
||||
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
c[BASE + 0x48..BASE + 0x4c].copy_from_slice(&0x61u32.to_le_bytes());
|
||||
c[BASE + 0x4c..BASE + 0x50].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
assert!(s.packets().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_billboard_record_is_read_and_checked() {
|
||||
let mut c = span_with_tail(0x40 + 8);
|
||||
// The billboard sits directly after the palette.
|
||||
let bb = BASE + 0x30 + SHRUB_NORMAL_COUNT * 8;
|
||||
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((bb - BASE) as u32).to_le_bytes());
|
||||
c[bb..bb + 4].copy_from_slice(&50.0f32.to_le_bytes());
|
||||
// TEX1: K = -217, MMIN = 4, address 0x14.
|
||||
c[bb + 0x10..bb + 0x12].copy_from_slice(&(-217i16).to_le_bytes());
|
||||
c[bb + 0x14..bb + 0x18].copy_from_slice(&4u32.to_le_bytes());
|
||||
c[bb + 0x18..bb + 0x1c].copy_from_slice(&0x14u32.to_le_bytes());
|
||||
// TEX0: data_lo = 1, address 0x06.
|
||||
c[bb + 0x20..bb + 0x24].copy_from_slice(&1u32.to_le_bytes());
|
||||
c[bb + 0x28..bb + 0x2c].copy_from_slice(&0x06u32.to_le_bytes());
|
||||
// MIPTBP1_1: address 0x34.
|
||||
c[bb + 0x38..bb + 0x3c].copy_from_slice(&0x34u32.to_le_bytes());
|
||||
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
let b = s.billboard().unwrap().expect("billboard present");
|
||||
assert_eq!(b.fade_distance, 50.0);
|
||||
assert_eq!(b.k, -217);
|
||||
assert_eq!(b.mmin, 4);
|
||||
assert_eq!(b.tex0, 1);
|
||||
assert_eq!(b.tex1_addr, 0x14);
|
||||
assert_eq!(b.tex0_addr, 0x06);
|
||||
assert_eq!(b.miptbp_addr, 0x34);
|
||||
// The palette is untouched by the billboard sitting after it.
|
||||
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
|
||||
assert!(s.normals().all(|n| n.is_unit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_billboard_offset_past_the_span_is_rejected() {
|
||||
let mut c = span_with_tail(0x40);
|
||||
// Far past the span's end.
|
||||
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&9000u32.to_le_bytes());
|
||||
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -255,37 +732,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_records_split_into_eights_and_check_their_tail() {
|
||||
let c = span();
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
// The fixture fills 0xab, so every record's tail is wrong.
|
||||
assert!(!s.body_is_well_formed());
|
||||
assert_eq!(s.body_record_count(), (0x40 - 0x30) / 8);
|
||||
// Fix the tails and it is well formed.
|
||||
let mut c2 = span();
|
||||
for i in 0..((0x40 - 0x30) / 8) {
|
||||
c2[BASE + 0x30 + i * 8 + 6] = 0;
|
||||
c2[BASE + 0x30 + i * 8 + 7] = 0;
|
||||
}
|
||||
let s2 = ShrubHeader::region(&c2, BASE as i32, 1072, c2.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
assert!(s2.body_is_well_formed());
|
||||
assert_eq!(s2.body_records().count(), s2.body_record_count());
|
||||
fn a_nonpositive_radius_or_scale_is_rejected() {
|
||||
let mut c = span();
|
||||
c[BASE + 0x0c..BASE + 0x10].copy_from_slice(&0.0f32.to_le_bytes());
|
||||
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
|
||||
|
||||
let mut c = span();
|
||||
c[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.0f32.to_le_bytes());
|
||||
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_shorter_than_one_record_is_not_well_formed() {
|
||||
fn a_nonzero_reserved_word_is_rejected() {
|
||||
let mut c = span();
|
||||
// Point the data at the last four bytes of the span.
|
||||
c[BASE + 0x2c..BASE + 0x30].copy_from_slice(&(0x3cu32).to_le_bytes());
|
||||
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
|
||||
.unwrap()
|
||||
.expect("present");
|
||||
assert_eq!(s.body_record_count(), 0);
|
||||
assert!(!s.body_is_well_formed());
|
||||
c[BASE + 0x18..BASE + 0x1c].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,19 +151,34 @@ const MAX_LIGHTS: usize = 64;
|
||||
impl LightBank {
|
||||
/// Parse the bank from a level's `gameplay` extent.
|
||||
///
|
||||
/// The header at +0x04 is an offset to a count followed by the sets; a
|
||||
/// negative count means the level has no lights.
|
||||
/// The extent opens with a table of section offsets in bytes; the word at
|
||||
/// **+0x04** is the directional-light section's offset. That section is an
|
||||
/// `i32` set count, twelve bytes of pad, then the sets. This was read the
|
||||
/// other way round — offset at +0x00, count at +0x04 — until the PCSX2
|
||||
/// comparison showed the pass running on denormal garbage and reporting
|
||||
/// every vertex lit; see `docs/plan/pcsx2-verification.md`.
|
||||
pub fn parse(gameplay: &[u8]) -> Result<LightBank> {
|
||||
let mut b = Buf::new(gameplay);
|
||||
b.skip(4, "gameplay extent header")?;
|
||||
let ofs = b.u32("light bank offset")? as usize;
|
||||
let count = b.i32("directional light count")?;
|
||||
if ofs + 0x10 > gameplay.len() {
|
||||
return Err(Error {
|
||||
offset: ofs,
|
||||
context: "light bank offset runs past the gameplay extent",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: 0x10,
|
||||
available: gameplay.len().saturating_sub(ofs),
|
||||
},
|
||||
});
|
||||
}
|
||||
let count = i32::from_le_bytes(gameplay[ofs..ofs + 4].try_into().unwrap());
|
||||
if count < 0 {
|
||||
return Ok(LightBank::default());
|
||||
}
|
||||
let n = (count as usize).min(MAX_LIGHTS);
|
||||
let mut sets = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
sets.push(read_set(gameplay, ofs + 0x10 + sets.len() * 0x40)?);
|
||||
for i in 0..n {
|
||||
sets.push(read_set(gameplay, ofs + 0x10 + i * 0x40)?);
|
||||
}
|
||||
Ok(LightBank {
|
||||
sets,
|
||||
@@ -264,8 +279,32 @@ mod tests {
|
||||
#[test]
|
||||
fn a_level_with_no_lights_parses_as_empty() {
|
||||
let mut g = vec![0u8; 32];
|
||||
g[0x04..0x08].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
g[0x04..0x08].copy_from_slice(&8u32.to_le_bytes());
|
||||
g[0x08..0x0c].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
let b = LightBank::parse(&g).expect("should parse");
|
||||
assert!(b.sets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bank_offset_is_at_plus_four_not_zero() {
|
||||
// A synthetic extent laid out the way the game writes it: an offset at
|
||||
// +0x04, a count at that offset, then the sets at +0x10.
|
||||
let mut g = vec![0u8; 0x100];
|
||||
let at = 0x40usize;
|
||||
g[0x04..0x08].copy_from_slice(&(at as u32).to_le_bytes());
|
||||
g[at..at + 4].copy_from_slice(&1i32.to_le_bytes());
|
||||
let set = |g: &mut [u8], o: usize, v: [f32; 4]| {
|
||||
for (i, f) in v.iter().enumerate() {
|
||||
g[o + i * 4..o + i * 4 + 4].copy_from_slice(&f.to_bits().to_le_bytes());
|
||||
}
|
||||
};
|
||||
set(&mut g, at + 0x10, [1.0, 2.0, 3.0, 4.0]);
|
||||
set(&mut g, at + 0x20, [0.0, 0.0, -1.0, 0.0]);
|
||||
let b = LightBank::parse(&g).expect("should parse");
|
||||
assert_eq!(b.sets.len(), 1);
|
||||
assert_eq!(b.sets[0].color_a, [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(b.sets[0].dir_a, [0.0, 0.0, -1.0, 0.0]);
|
||||
// The old reading — offset at +0, count at +4 — must not be what this
|
||||
// does: at +0 the extent is 0, so it would find an empty bank.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ than inventing their own.
|
||||
| `sky` | `sky` | `collision` |
|
||||
| `collision`| `collision` | first moby class |
|
||||
| `moby` | first moby | first tie class |
|
||||
| `tie` | first tie | first shrub class |
|
||||
| `shrub` | first shrub | lump length − 0x38A10 |
|
||||
| `tie` | first tie | first shrub class, |
|
||||
| | | else chunk container |
|
||||
| `shrub` | first shrub | chunk container |
|
||||
| `chunks` | chunk container| lump length − 0x38A10|
|
||||
|
||||
Class regions start at the lowest non-zero geometry offset in their class
|
||||
table. A zero entry means *absent*, not position zero -- the same trap the
|
||||
@@ -43,26 +45,79 @@ Comparing all 27 levels byte-for-byte from the end: the last **231,952 bytes
|
||||
therefore ends at `len - 0x38A10`, and the final region is bounded there rather
|
||||
than at `core_data.len()`.
|
||||
|
||||
This is what closes shrub. Across the 24 levels that have a shrub region it
|
||||
gives **24,442,928 bytes** total, and it is the same bound the disc test
|
||||
asserts on every level.
|
||||
This is what closes the chunk container. The regions it separates from level
|
||||
content are measured on the disc: **2,033,776 bytes of shrub region across the
|
||||
24 levels that have one, and 25,775,850 bytes of chunk container** across all
|
||||
27. The disc test asserts the bound on every level.
|
||||
|
||||
What the trailer *is* is not established. It may be fixed data, or padding
|
||||
from a fixed-size decompression buffer. Either way it is constant, which is
|
||||
all that is needed to bound the tail.
|
||||
|
||||
## Open: what was in the region before it
|
||||
## Found: the chunk container closes the class region
|
||||
|
||||
The offset ending the shrub region is not in the core index.
|
||||
The offset ending the shrub region is not in the core index, and the class
|
||||
table that precedes the container carries no successor offset for it -- unlike
|
||||
moby and tie, whose boundaries fall out of the tables. Texture texels do not
|
||||
bound it either: they sit before moby, not after shrub. So the boundary is
|
||||
located in `core_data` itself.
|
||||
|
||||
Texture texels do **not** bound it. They sit before moby, not after shrub: on
|
||||
level 0 the texel range is 3539328..5326208 while the last shrub class starts
|
||||
at 16044288, leaving ~925 KB of real, non-zero data after the last shrub class
|
||||
that nothing yet accounts for.
|
||||
**Range measured:** for each level, from the last shrub class's geometry
|
||||
offset to the constant trailer. Nothing outside that range is read.
|
||||
|
||||
`core_layout` reports `end: None` for shrub on the 24 levels that have one, and
|
||||
the disc test asserts it, so the gap stays visible rather than being papered
|
||||
over by clamping to the end of the lump.
|
||||
**Method:** scan forward from the last shrub class for the pair (a) the
|
||||
constant two bytes `f6 7c`, sitting immediately in front of (b) a chunk
|
||||
signature -- a 16-byte header ending `0xff`, ten zero bytes, then four or more
|
||||
`0xf0`-tagged offsets. `gc_formats::chunks::chunk_region_start`.
|
||||
|
||||
Each half of that signature is weak on its own, which is why the halves are
|
||||
measured separately:
|
||||
|
||||
- the pair `f6 7c` occurs **25 to 103 times** in a 5 to 17 MB lump, so
|
||||
matching it alone picks dozens of wrong offsets;
|
||||
- the chunk shape matches random data often enough that `parse_chunk` demands
|
||||
four tagged offsets before a signature is believed.
|
||||
|
||||
**Result: on all 27 levels the pair sits exactly two bytes before the first
|
||||
chunk header.** The two are never found apart. This is the boundary, and
|
||||
`core_layout` closes shrub with it -- `shrub`'s end is the container's start,
|
||||
and the container's own end is the trailer, so every byte of level-specific
|
||||
`core_data` now belongs to a named region.
|
||||
|
||||
Two controls, both asserted by the disc test `chunk_container_closes_the_last_class_region`:
|
||||
|
||||
- **No signature inside the body.** Scanning the last shrub body for a bare
|
||||
chunk signature finds none, on any level -- so the boundary search was not
|
||||
matching something it should have skipped, and the marker's job is
|
||||
corroboration rather than load-bearing disambiguation.
|
||||
- **The three levels without shrub** (10, 25, 26) carry the same marker two
|
||||
bytes before the container. There the container closes *tie* instead, which
|
||||
is what makes the locator a property of the region rather than a shrub
|
||||
rule.
|
||||
|
||||
On level 0:
|
||||
|
||||
| what | range | bytes |
|
||||
|---|---|---|
|
||||
| last shrub class data | 16046800 .. 16047040 | 240 |
|
||||
| marker `f6 7c` | 16047040 .. 16047042 | 2 |
|
||||
| **chunk container** | 16047042 .. 16737344 | **690,302** |
|
||||
| constant trailer | 16737344 .. 16969296 | 231,952 |
|
||||
|
||||
The marker's meaning is not established. It is constant across all 27 levels
|
||||
and it appears at exactly this position, but whether it is a terminator, an end
|
||||
count or a tag is unknown, so it is not named.
|
||||
|
||||
**Correction this produces.** The chunk-container measurements recorded below
|
||||
were taken over "the 900,000 bytes before the constant trailer" -- a window
|
||||
wide enough to contain the container on every level but not aligned to its
|
||||
true extent. Chunks sitting at the window's edge were truncated (27 of 1,123),
|
||||
and the region's size was never correctly stated. Both are now bounded
|
||||
properly: on level 0 the container is 690,302 bytes, not ~900,000, and the
|
||||
25,775,850 bytes of chunk data across 27 levels are the measured total rather
|
||||
than a windowed sample. The `chunk_containers_resolve_on_every_level` test now
|
||||
reads the region from `core_layout` instead of measuring backwards from the
|
||||
end of the lump.
|
||||
|
||||
## Correction: it is mostly texture data, not an unnamed region
|
||||
|
||||
|
||||
+220
-3
@@ -714,9 +714,15 @@ the fidelity rule.
|
||||
resolving texture addresses on the host.
|
||||
- **The guard-band clip test** that sends patches crossing the guard band to
|
||||
the clipping program.
|
||||
- **Lighting magnitudes.** The pass runs and the output is plausible; whether
|
||||
it matches the game needs comparison against reference frames, which needs
|
||||
the PCSX2 loop that is still to come. Nothing in this file establishes it.
|
||||
- ~~**Lighting magnitudes.**~~ **Settled 2026-10-11** by the PCSX2
|
||||
comparison loop (`docs/plan/pcsx2-verification.md`): level 0's 162,400
|
||||
colour bytes match the game's EE RAM exactly. Two bugs the comparison found
|
||||
and fixed, both of which produced output that looked plausible:
|
||||
`vertex_normal_bits` negated x and y but not z (so `max(dA, dA*wA)` took the
|
||||
wrong branch and every vertex lit near its base colour -- 95.88% agreement
|
||||
instead of 100%), and a record whose `select` names a set the bank lacks
|
||||
drew transparent black where the game writes the record's base colour (3,130
|
||||
of level 0's 40,600 vertices, `select 0x000f`).
|
||||
- **tie, moby and shrub geometry.** The class tables parse and the container
|
||||
they point into is identified (below), but none of the three is decoded yet.
|
||||
Moby is the largest of the three and is skeletal, with an animation evaluator
|
||||
@@ -961,3 +967,214 @@ This is the second time in this format that a confident aggregate hid a bad
|
||||
bound -- the first was the VIF reading. The pattern is worth noting: aggregates
|
||||
across many spans are only as trustworthy as the span bounds underneath them,
|
||||
and those are the easy thing to get wrong because nothing errors.
|
||||
|
||||
## Shrub span bounds: resolved (2026-10-11, later still)
|
||||
|
||||
The offset that ends the shrub region is the **start of the chunk container**,
|
||||
and it is found by signature rather than read from the core index, which has no
|
||||
successor offset for shrub.
|
||||
|
||||
**Range measured:** for each level, from the last shrub class's geometry offset
|
||||
to the constant trailer. Nothing outside that range is read.
|
||||
|
||||
**Method:** scan forward from the last shrub class for two bytes `f6 7c`
|
||||
sitting immediately in front of a chunk signature (16-byte header ending `0xff`,
|
||||
ten zero bytes, four or more `0xf0`-tagged offsets). Each half of that
|
||||
signature is weak alone -- the marker pair occurs 25 to 103 times per lump --
|
||||
and together they are exact.
|
||||
|
||||
**Result: on all 27 levels the marker sits exactly two bytes before the first
|
||||
chunk header.** The two parts of the signature are never found apart, and no
|
||||
chunk signature occurs inside any last shrub body. The three levels without
|
||||
shrub (10, 25, 26) carry the same marker before the container that closes
|
||||
*tie*, so the locator is a property of the region, not a shrub rule.
|
||||
`gc_formats::chunks::chunk_region_start` implements it and
|
||||
`regions::core_layout` closes shrub with it.
|
||||
|
||||
Consequences, all measured over the new bounds:
|
||||
|
||||
- **All 358 shrub class bodies are bounded and well formed** -- every one is
|
||||
whole 8-byte records. That was not assertable while the last class in each
|
||||
level ran out to the lump's end, and it is the check the boundary earns.
|
||||
- **9,600 records in total**, against the 8,938 previously measurable in
|
||||
successor-bounded spans; 662 of them sit in the 24 last-class bodies, 24 to
|
||||
30 per level. The retracted "3.5 million records / 28 MB of shrub body"
|
||||
figure stays retracted: the correct total for the shrub region is
|
||||
**2,033,776 bytes** across the 24 levels that have one.
|
||||
- The chunk container is **25,775,850 bytes across 27 levels** (1,236 chunks,
|
||||
22,562 records), not an ~690-900 KB afterthought per level. The earlier
|
||||
container measurements were taken over "the 900,000 bytes before the
|
||||
trailer", a window that did not align to the container's real extent: on
|
||||
level 0 it is 690,302 bytes, and 27 of 1,123 chunks were truncated at that
|
||||
invented edge. The chunk test now reads the region from `core_layout`.
|
||||
|
||||
What is still not known: the marker's meaning (constant across 27 levels, at
|
||||
exactly this position, unnamed). The shrub body payload's encoding is the next
|
||||
section.
|
||||
|
||||
## The shrub class blob: palette and billboard (2026-10-11, after the bound)
|
||||
|
||||
With every span bounded by the chunk container, the shrub class blob reads
|
||||
cleanly against the R&C1 format (`vendor/rerac/docs/formats/shrub_sky_rac1.md`,
|
||||
derived from the Wrench sources). R&C1's shrub class header keeps the same
|
||||
field offsets R&C2 shows -- `o_class` at 0x24, a count at 0x28, the data offset
|
||||
at 0x2c -- so the reference says which bytes to look at and the disc says what
|
||||
they hold.
|
||||
|
||||
**Range measured:** all 358 shrub class spans over the 24 levels that have
|
||||
them, each bounded by its successor class or the located chunk container.
|
||||
Nothing outside the spans is read.
|
||||
|
||||
### The body is a 24-entry normal palette
|
||||
|
||||
Two things made this identifiable where three stride-fitting attempts had
|
||||
failed. First the shape: every body is 8-byte records whose last two bytes are
|
||||
zero, and R&C1's palette is documented as 24 records of exactly that shape
|
||||
(`s16` x, y, z, pad). Second the count: bodies hold 24, 26, 28 or 30 records
|
||||
with the extra ones **always all zero and always at index 24 or later** --
|
||||
never a live entry. So the body is 24 normals plus zero padding, and the
|
||||
padding is what distinguishes it from geometry.
|
||||
|
||||
The palette is real normals, and the test says how firmly:
|
||||
|
||||
- all 8,592 live entries have squared length within **65,535** of `32767^2`,
|
||||
and the bound is tight -- the largest observed deviation is exactly 65,535.
|
||||
That number is not fitted: it is `32768^2 - 32767^2`, which is what a unit
|
||||
vector scaled by 32768 produces when an axis-aligned normal quantises to
|
||||
`-32768` while the positive axis clamps to `32767`. Components run the full
|
||||
`s16` range (-32768..32767) and 1,765 of the 8,592 entries are axis-aligned,
|
||||
which is what a coarse direction codebook looks like.
|
||||
- a random `s16` triple has squared length spread about a *billion* either
|
||||
side of `32767^2`, so one entry in fifteen thousand would pass by chance.
|
||||
All 8,592 pass.
|
||||
- the clincher: 17 classes carry a palette of 24 identical `(0, 0, 1)`
|
||||
entries. A position or index table cannot produce that; a normal palette for
|
||||
a flat-facing class must.
|
||||
|
||||
This is also where shrub lighting comes from -- R&C1 lights each instance
|
||||
against its class's 24-direction palette. Unlike the tfrag's per-vertex light
|
||||
records, which store azimuth/elevation *indices* into the overlay's sine table,
|
||||
the shrub palette stores the directions outright, so the overlay table is not
|
||||
needed for shrubs.
|
||||
|
||||
### The header, byte by byte
|
||||
|
||||
**Range measured:** the 0x30 bytes of every span.
|
||||
|
||||
| offset | field | measured |
|
||||
|---|---|---|
|
||||
| 0x00 | f32[4] `sphere` | centre (xyz) + radius (w). Finite on all 358, **radius positive on all 358** (1.72..41.57), centres near the class's own origin |
|
||||
| 0x10 | f32 `size` | 3.0..20.0, positive on all 358 |
|
||||
| 0x14 | u16 `mode_bits` | **0 on 342 spans, 4 on 16** |
|
||||
| 0x16 | u16 `scratch` | **0 on all 358** (asserted by the parser) |
|
||||
| 0x18 | u32 `reserved` | **0 on all 358** (asserted by the parser) |
|
||||
| 0x1c | u32 `billboard_offset` | 0 on 333 spans, in-span on 25 |
|
||||
| 0x20 | f32 `scale` | 0.0044..1.8, positive on all 358 |
|
||||
| 0x24 | u32 `o_class` | equals the class entry's own, 358/358 |
|
||||
| 0x28 | u32 `count` | 1..55, meaning not established |
|
||||
| 0x2c | u32 `data_offset` | inside the span, 358/358 |
|
||||
|
||||
Two corrections this produces, both recorded because the old readings were
|
||||
confidently wrong rather than merely unverified:
|
||||
|
||||
- **"reserved is 0 or a denormal" was the wrong shape.** The 16 spans with a
|
||||
non-zero value there have `mode_bits = 4` and `scratch = 0`, not the other
|
||||
way round. As an `f32` the word at `+0x14` is still a denormal, so the PS2
|
||||
reads zero either way -- but the field is a flag word, not a magnitude, and
|
||||
the demo is the point: the EE's FPU has no denormals, so `04000000` and zero
|
||||
are the same value to the hardware.
|
||||
- **"bounds" is a bounding sphere, not a min/max box.** The old reading tested
|
||||
`min.xyz/max.xyz` ordering, found it on only 146 of 358 spans, and stopped
|
||||
naming the field. It is centre + radius, as in R&C1: the fourth float is
|
||||
positive on all 358 spans and the centres cluster near the class origin
|
||||
within +-24, which is where a class-local sphere belongs. The 146/358 figure
|
||||
was a property of the wrong question.
|
||||
|
||||
### The billboard record
|
||||
|
||||
The billboard offset at `+0x1c` is the strongest self-identification in the
|
||||
header after `o_class`, because it is **confirmed from a second file**: it is
|
||||
non-zero on exactly the 25 classes whose core-index entry carries billboard
|
||||
texture fields, in both directions -- 25 records and 25 entries, pairing
|
||||
correctly. R&C1 states the same correspondence ("a class has a billboard record
|
||||
exactly when its core-index billboard texture has width != 0").
|
||||
|
||||
At the offset sits R&C1's `ShrubBillboard` shape, register block and all:
|
||||
|
||||
| offset | field | measured |
|
||||
|---|---|---|
|
||||
| 0x00 | f32 `fade_distance` | 5..1000; 512 reads as 0 under R&C1's `trunc(fade) & 0xff`, i.e. billboard-only |
|
||||
| 0x04 | f32[3] | **not established** -- see below |
|
||||
| 0x10 | TEX1_1: K, MMIN, address | K -233..-168, MMIN 4 on all 25, address 0x14 |
|
||||
| 0x20 | TEX0_1: data_lo, address | index 1..4, address 0x06 |
|
||||
| 0x30 | MIPTBP1_1: address | 0x34, data words zero (filled at level load) |
|
||||
|
||||
The three floats at `+0x04..+0x10` are the part that does *not* carry over.
|
||||
R&C1's are `width`, `height` and `z_ofs` in class units; R&C2's are an order of
|
||||
magnitude too large and too varied to be dimensions of a 32..128 px billboard,
|
||||
and they **repeat byte-identically across levels**: 16 distinct triples among
|
||||
the 25 records, with one appearing on four classes across levels 1, 4, 12 and
|
||||
21. Dimensions would not do that; shared art assets would. They are handed back
|
||||
unnamed.
|
||||
|
||||
Two further facts about them, measured: the whole record repeats with them --
|
||||
three classes on different levels carry identical `fade`, floats, K and scale
|
||||
-- and the class entries they belong to carry identical `sphere` too. So the
|
||||
blobs themselves are shared between levels, which is the same behaviour the
|
||||
chunk container showed with its byte-identical tables.
|
||||
|
||||
### The packets: the mesh geometry is in the blob (2026-10-11, later)
|
||||
|
||||
The open question above -- where the mesh geometry lives -- answered itself
|
||||
once the running game was consulted. The running PCSX2 loads shrub classes
|
||||
into RAM, and diffing a loaded blob against its disc bytes shows what the
|
||||
loader rewrites:
|
||||
|
||||
- **`+0x40` holds a table of `count` entries of `(offset, size)`**, both
|
||||
relative to the span start on disc, rewritten to absolute addresses in RAM
|
||||
with the sizes unchanged. Verified on level 1 class 1: all 21 entries match
|
||||
the disc offset for offset and the size for size after adding the blob's
|
||||
RAM base.
|
||||
- **`+0x2c` (the palette offset) is relocated** the same way, to an absolute
|
||||
pointer.
|
||||
- **`+0x18` -- zero on disc, asserted by the parser -- is written with a
|
||||
pointer at load**, to a table of 0x20-byte records that look instance-shaped
|
||||
(a position, a size, a count, and a `0xffff` light slot). Not established;
|
||||
recorded because it is where the next question starts.
|
||||
|
||||
So the blob is header, packet table, packets, [billboard], palette -- the same
|
||||
shape as R&C1's, and the mesh was here the whole time. The reason it was
|
||||
missed is worth writing down: **`data_offset` points at the *end* of the
|
||||
span**, so reading the body as `data_offset..span_end` accounts for the last
|
||||
few hundred bytes and leaves the 20 KB in front of it unexamined. A bound that
|
||||
is correct and a body that is small enough to look plausible are together
|
||||
enough to hide a region.
|
||||
|
||||
**Range measured:** every shrub class span on the disc -- 358 spans over 24
|
||||
levels, each bounded by its successor class or the located chunk container.
|
||||
|
||||
**Method:** for each span, read `count` from `+0x28`, then the entries at
|
||||
`+0x40`, and require them to tile: the first packet starts at
|
||||
`align16(0x40 + count * 8)` -- the table, rounded up to 16 -- every entry is
|
||||
exactly `size` past the previous one, and the last ends at the palette, or
|
||||
exactly 0x40 short of it on the spans that carry a billboard record in
|
||||
between.
|
||||
|
||||
**Result: 358 of 358 tile exactly, holding 2,239 packets.** No span's
|
||||
deviations: the first-packet offsets, the sizes and the ends all agree with
|
||||
the rule. That is what pins the field meanings -- `count` is the **packet
|
||||
count** (1..55 on the disc), and `data_offset` is the palette's offset -- and
|
||||
it is why the earlier "the body is not sized by count" observation was true
|
||||
for a reason that had not been understood: the body is not sized by the count
|
||||
because the count sizes the packets.
|
||||
|
||||
`gc_formats::shrub::Shrub::packets()` exposes them, and returns `None` rather
|
||||
than a partial list when a table does not tile.
|
||||
|
||||
### Still open
|
||||
|
||||
- The three billboard floats, `size`, and `mode_bits` (4 on 16 spans).
|
||||
- The packet contents. They are VIF command lists in R&C1; the R&C2 packets
|
||||
have not been decoded, and the VIF reading of a shrub body was already
|
||||
retracted once, so this needs a control, not a pattern match.
|
||||
- The 0x20-byte records the loader points at from `+0x18.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# PCSX2 verification (`gc-trace`)
|
||||
|
||||
Status as of **2026-10-11**. The tfrag lighting magnitudes are verified; this
|
||||
is the file that records how, so the next comparison starts from the
|
||||
harness instead of from scratch.
|
||||
|
||||
Tool: `tools/trace` (`gc-trace`). It never ships, no shipped crate depends on
|
||||
it, and it reads the game's own memory out of a running emulator, a
|
||||
savestate, or a raw dump.
|
||||
|
||||
## What exists now
|
||||
|
||||
```text
|
||||
gc-trace pine-info # protocol handshake
|
||||
gc-trace dump-ee <src> [--out FILE] # 32 MiB EE RAM image
|
||||
gc-trace find <src> --bytes HEX # content search (the locators use it too)
|
||||
gc-trace read <src> --addr HEX --len N
|
||||
gc-trace info <src> # savestate metadata
|
||||
gc-trace compare-tfrag-light <unpacked-dir> [--level N] <src>
|
||||
```
|
||||
|
||||
`<src>` is `--pine [slot]`, `--state FILE` / `--state latest`, or `--ee FILE`.
|
||||
It may sit anywhere on the command line.
|
||||
|
||||
The comparison:
|
||||
|
||||
1. **Locates the tfrags block in RAM by content** -- the key is tfrag 0's
|
||||
bounding sphere, sixteen bytes unique in 32 MiB. A candidate is accepted
|
||||
only when the block header matches, **every** header matches the disc on
|
||||
the invariant bytes (everything but the relocated `data` pointer and the
|
||||
runtime occlusion index), and every `data` pointer is the relocated form
|
||||
`table + disc offset`. Anything less is a plausible wrong read.
|
||||
2. **Locates the light bank the same way**, by its exact section bytes.
|
||||
3. **Runs the pass** over the same disc data with
|
||||
`gc_engine::tfrag_light::light_tfrag`.
|
||||
4. **Diffs per channel, per vertex**, bucketed by vertex class, with the first
|
||||
few mismatching vertices' *terms* (`gc_engine::tfrag_light::
|
||||
light_vertex_terms`) printed so a mismatch can be attributed.
|
||||
|
||||
## Result: levels 0 and 1, verified 2026-10-11
|
||||
|
||||
Level 0 (Veldin), three inputs -- the live emulator over PINE and two PCSX2
|
||||
savestates from that level, all agreeing byte for byte:
|
||||
|
||||
```text
|
||||
level0: tfrags block at 0x848000, table at 0x848040, 833 tfrags
|
||||
headers matching the disc on invariant bytes: 833/833
|
||||
data pointers relocated as table + disc offset: 833/833
|
||||
light bank in RAM at 0x50ab20, byte-identical to the disc's
|
||||
|
||||
vertices compared: 40600 (162400 colour bytes)
|
||||
equal: 162400 (100.00%)
|
||||
runtime-set: 3130 vertices, 3130 fully equal
|
||||
single-set: 37470 vertices, 37470 fully equal
|
||||
```
|
||||
|
||||
Level 1, from savestate slot 2 and cross-checked against the live emulator:
|
||||
|
||||
```text
|
||||
level1: tfrags block at 0x84c000, table at 0x84c040, 401 tfrags
|
||||
headers matching the disc on invariant bytes: 401/401
|
||||
data pointers relocated as table + disc offset: 401/401
|
||||
light bank in RAM at 0x50f4a0, byte-identical to the disc's
|
||||
|
||||
vertices compared: 26470 (105880 colour bytes)
|
||||
equal: 105880 (100.00%)
|
||||
runtime-set: 129 vertices, 129 fully equal
|
||||
single-set: 26341 vertices, 26341 fully equal
|
||||
```
|
||||
|
||||
Level 1 matters more than one more tally: it is a different planet with a
|
||||
different geometry extent, a different light bank and a different
|
||||
runtime-set population, and it agrees on every byte. The two fixes below are
|
||||
therefore not level-0 coincidences.
|
||||
|
||||
What this establishes:
|
||||
|
||||
- **The tfrag locator and the pointer model are right.** 833 of 833 headers
|
||||
match on invariant bytes and 833 of 833 `data` pointers are relocated
|
||||
exactly `table + disc offset`, so the block found in RAM is the level's and
|
||||
the geometry read out of it is the geometry the game drew.
|
||||
- **The light bank framing is right.** The section found in RAM is
|
||||
byte-identical to the disc's: offset at `+0x04`, an `i32` set count, twelve
|
||||
bytes of pad, then 0x40-byte sets. The previous reading -- offset at
|
||||
`+0x00`, count at `+0x04` -- was wrong, and this is what settled it.
|
||||
- **The lighting magnitudes match exactly**: every one of the 162,400 colour
|
||||
bytes the game wrote equals ours. This was the open item in
|
||||
`docs/roadmap.md` and `docs/plan/level-assets.md` ("Lighting magnitudes are
|
||||
unverified: they need comparison against reference frames").
|
||||
|
||||
## Two bugs the comparison found
|
||||
|
||||
Both produced plausible output and neither was visible in a screenshot.
|
||||
|
||||
**1. The normal's z was not negated.** `vertex_normal_bits` negated x and y
|
||||
and left z positive, while the renderer's `NormalTable::normal` negates all
|
||||
three. The result: `dA = -0.707` where the game had `+0.707`, so
|
||||
`max(dA, dA·wA)` picked the wrong branch and every vertex lit near its base
|
||||
colour -- dark, but not *wrong* dark. Level 0 agreed on 95.88% of colour
|
||||
bytes with the bug and 100.00% without it.
|
||||
|
||||
**2. A record with no light set drew transparent black.** `select 0x000f`
|
||||
names set 15; the level's directional bank has 5 sets, so the pass had no set
|
||||
to light with and yielded `[0,0,0,0]`. The game writes the record's **base
|
||||
colour**: 3,130 of level 0's 40,600 vertices do this. The vertex is unlit, not
|
||||
invisible.
|
||||
|
||||
## Finding the next thing
|
||||
|
||||
- **Compare another level.** All three inputs available are level 0; another
|
||||
level needs a savestate taken in it (F1 in-game, or `gc-trace
|
||||
pine-savestate SLOT`). The harness takes `--level N` already.
|
||||
- **Point lights.** The runtime-set cluster is set 15 and above; those slots
|
||||
are filled by gameplay, so they cannot be verified from a level-load image.
|
||||
Comparing them needs a frame with point lights in it.
|
||||
- **Blended sets.** Both levels' vertices are all single-set (`select &
|
||||
0xff00 == 0`), so the blend path -- the only path `rsqrt` touches -- is
|
||||
untested against the game.
|
||||
- **Distil, then test.** A comparison is not a regression. When a fact from
|
||||
here becomes load-bearing, distil it into a numbers-only fixture under
|
||||
`tools/trace/tests/` and test against that; no test may read a savestate, a
|
||||
dump or a live emulator.
|
||||
|
||||
## The test setup
|
||||
|
||||
PCSX2 is installed and working on this machine, so every comparison above can
|
||||
be re-run rather than being a one-off observation.
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Emulator | `~/Desktop/pcsx2/pcsx2-v2.8.2-linux-appimage-x64-Qt.AppImage` — the directory name is **lowercase** on disk |
|
||||
| Game disc | `~/Desktop/pcsx2/games/Ratchet & Clank - Going Commando.iso`; verified target `SCUS_972.68` v1.01 NTSC ([`docs/disc-identification.md`](../disc-identification.md)) |
|
||||
| Emulator config | `~/.config/PCSX2/` (`sstates`, `bios`, `memcards`, `inis`, `logs`) |
|
||||
| BIOS | extracted into `~/.config/PCSX2/bios/` |
|
||||
| Savestates | `~/.config/PCSX2/sstates/SCUS-97268 (38996035).NN.p2s`, `NN` = slot |
|
||||
| Unpacked disc | `~/Documents/gc-extracted/`, all 27 levels |
|
||||
| PINE socket | `$XDG_RUNTIME_DIR/pcsx2.sock` (`/run/user/1000/pcsx2.sock`), slot 28011 |
|
||||
|
||||
The emulator boots the disc with `-fastboot`; a level takes about a minute
|
||||
from a cold start to reach. To capture a state manually, press F1 in-game (it
|
||||
lands in the slot's `.p2s`); to capture from a script, `gc-trace
|
||||
pine-savestate SLOT` asks the emulator to write one.
|
||||
|
||||
The disc integration tests need the image, not the emulator:
|
||||
|
||||
```sh
|
||||
GC_DISC="$HOME/Desktop/pcsx2/games/Ratchet & Clank - Going Commando.iso" \
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cargo run -q -p gc-trace -- pine-info
|
||||
cargo run -q -p gc-trace -- compare-tfrag-light ~/Documents/gc-extracted --level 0 --pine
|
||||
cargo run -q -p gc-trace -- compare-tfrag-light ~/Documents/gc-extracted --level 0 \
|
||||
--state ~/.config/PCSX2/sstates/<name>.p2s
|
||||
```
|
||||
|
||||
Exit status is 0 when every byte matches, 2 on mismatches, 1 on error, so it
|
||||
can be run as a check. PINE must be enabled in the emulator (Settings →
|
||||
Advanced); the socket is `$XDG_RUNTIME_DIR/pcsx2.sock` on Linux.
|
||||
|
||||
**Nothing here redistributes game data.** The disc, the BIOS and the
|
||||
savestates are the user's own; the repository holds the tool and the numbers.
|
||||
+37
-8
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
Status as of **2026-10-09**.
|
||||
Status as of **2026-10-11**.
|
||||
|
||||
A from-scratch native Rust reimplementation of Ratchet & Clank: Going Commando
|
||||
(PS2). Each phase ends in a **gate** — something checkable, not a feeling that
|
||||
@@ -57,8 +57,22 @@ early, while they are still cheap to retire.
|
||||
through `LightTfrags`. The renderer still counts and reports how many
|
||||
vertices it lit, because a silent fallback to stored colours would look
|
||||
plausible and be wrong.
|
||||
- [ ] Lighting *magnitudes* are unverified: they need comparison against
|
||||
reference frames, which needs the PCSX2 comparison loop.
|
||||
- [x] **The shrub region is bounded, and with it the whole lump.** Shrub's end
|
||||
was the last unlocated region bound: it is the start of the post-class
|
||||
chunk container, found by a marker-paired signature in `core_data` (the
|
||||
index carries no successor offset). The container closes shrub on the
|
||||
24 shrub levels and tie on the three without. All 358 shrub class bodies
|
||||
are now bounded and are whole 8-byte records, so shrub measurements are
|
||||
trustworthy for the first time -- the "3.5M records" figure from the
|
||||
unbounded final span is replaced by a measured 9,600.
|
||||
- [x] **Lighting *magnitudes* are verified** by the PCSX2 comparison loop.
|
||||
`gc-trace compare-tfrag-light` runs the pass over level 0's disc data and
|
||||
diffs it against the game's own lit colours in EE RAM (live over PINE, or
|
||||
from a savestate): **all 162,400 colour bytes match**, across 833/833
|
||||
headers relocated and the light bank byte-identical. Two bugs it found:
|
||||
the normal's z was not negated (dark but plausible, 95.88% agreement),
|
||||
and records with no light set drew transparent black instead of the base
|
||||
colour. See `docs/plan/pcsx2-verification.md`.
|
||||
- [ ] Then the GS register set and the guard-band clip test.
|
||||
|
||||
The disc being the exact build the reference projects pin is the single
|
||||
@@ -153,6 +167,9 @@ Phase 1 hands over the extents; this phase works out what each one contains.
|
||||
- [x] Region layout of `core_data` established: tfrag, occlusion, sky, collision
|
||||
and `textures_base` are offsets, strictly increasing on all 27 levels.
|
||||
Terrain is `[0, occlusion)` and carries inline texels.
|
||||
- [x] Region layout **closed**: the chunk container's marker-paired signature
|
||||
ends the last class region, and the constant trailer ends the container.
|
||||
Every byte of level-specific `core_data` now belongs to a named region.
|
||||
- [x] **tfrag vertex format: recovered.** The tfrags block header gives a table
|
||||
offset (`0x40`) and a patch count; each patch is a 0x40-byte header whose
|
||||
`data` offset is relative to the table, not to the block. Positions are
|
||||
@@ -206,11 +223,23 @@ Phase 1 hands over the extents; this phase works out what each one contains.
|
||||
- [ ] `tie` — instanced environment geometry, fat vertices
|
||||
- [~] `shrub` — class table done (48-byte entries, not 32; reading shrub at
|
||||
32 still gives 231 sane-looking entries of 358, which is the hazard).
|
||||
Geometry header parsed and both fields **checked**, not merely reported:
|
||||
`+0x24` equals the entry's own `o_class`, and `+0x2c` is an in-span data
|
||||
offset, on all 358 spans. Shrub does **not** use moby's part-table format.
|
||||
The body past `+0x2c` is not decoded — it tiles at no stride consistent
|
||||
with the count at `+0x28`.
|
||||
Geometry header parsed and checked field by field on all 358 spans:
|
||||
`+0x24` equals the entry's own `o_class`, `+0x2c` is an in-span data
|
||||
offset, `+0x16`/`+0x18` are zero, and `+0x1c` names a billboard on
|
||||
exactly the 25 classes whose core-index entry carries billboard texture
|
||||
fields. Shrub does **not** use moby's part-table format. The body is a
|
||||
**24-entry palette of unit-length s16 normals** (squared length within
|
||||
65,535 of 32767² on all 8,592 entries — a random triple would miss by a
|
||||
billion) plus zero padding records; the billboard record carries K, MMIN
|
||||
and the TEX0/MIPTBP addresses as R&C1's does. What remains open: the
|
||||
three floats in the billboard record, `size`, `mode_bits`, `count`, and
|
||||
**where the mesh geometry lives** — R&C1 puts the mesh packets in this
|
||||
blob and R&C2's blob holds only the palette.
|
||||
- [x] **The shrub mesh geometry is in the class blob.** A packet table at
|
||||
`+0x40` names `count` packets between the table and the palette, and
|
||||
they tile on **358 of 358 spans** (2,239 packets), which makes `count`
|
||||
the packet count. Confirmed against the running game, which relocates
|
||||
the table's offsets to absolute addresses and `data_offset` likewise.
|
||||
- [x] `moby` — per-class model geometry. A class span opens with a `u32`
|
||||
pointing at a part table of 16-byte records; parts tile exactly, with
|
||||
`vertex_offset + 16 * vertex_count == next_part` holding for **43,467 of
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "gc-trace"
|
||||
description = "PCSX2 ground-truth comparison harness: reads the game's own memory and diffs it against the port"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
gc-formats = { workspace = true }
|
||||
gc-engine = { workspace = true }
|
||||
ruzstd = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
@@ -0,0 +1,121 @@
|
||||
//! EE RAM: a flat 32 MiB window, plus the content search the locators use.
|
||||
//!
|
||||
//! The harness never hardcodes an address. Every structure the comparison
|
||||
//! needs is found by content, because the addresses differ per level and per
|
||||
//! run, and a pointer read from the wrong base is indistinguishable from a
|
||||
//! right read from the right one. `EeRam` is deliberately dumb: byte-range
|
||||
//! reads and a substring search.
|
||||
|
||||
/// The PlayStation 2's main RAM: 32 MiB at physical 0 (no extended RAM).
|
||||
pub const EE_SIZE: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// A borrowed EE RAM image.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EeRam<'a>(&'a [u8]);
|
||||
|
||||
impl<'a> EeRam<'a> {
|
||||
/// Wrap a RAM image. The length must be exactly 32 MiB, so a short dump is
|
||||
/// an error rather than a search that silently stops early.
|
||||
pub fn new(bytes: &'a [u8]) -> anyhow::Result<Self> {
|
||||
if bytes.len() != EE_SIZE {
|
||||
anyhow::bail!(
|
||||
"EE RAM image is {} bytes, expected exactly {EE_SIZE}",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &'a [u8] {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Every offset at which `needle` occurs, scanning 32 MiB.
|
||||
///
|
||||
/// A naive first-byte scan keeps this correct for any needle, including
|
||||
/// one inside a buffer that also contains zeros — which is most of RAM.
|
||||
pub fn find_all(&self, needle: &[u8]) -> Vec<usize> {
|
||||
if needle.is_empty() || needle.len() > self.0.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let first = needle[0];
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i + needle.len() <= self.0.len() {
|
||||
if self.0[i] == first && &self.0[i..i + needle.len()] == needle {
|
||||
out.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first offset at which `needle` occurs.
|
||||
pub fn find(&self, needle: &[u8]) -> Option<usize> {
|
||||
self.find_all(needle).into_iter().next()
|
||||
}
|
||||
|
||||
pub fn slice(&self, at: usize, len: usize) -> Option<&'a [u8]> {
|
||||
self.0.get(at..at.checked_add(len)?)
|
||||
}
|
||||
|
||||
/// A little-endian `i32` at `at`.
|
||||
pub fn i32(&self, at: usize) -> Option<i32> {
|
||||
self.slice(at, 4)
|
||||
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// A little-endian `u32` at `at`.
|
||||
pub fn u32(&self, at: usize) -> Option<u32> {
|
||||
self.slice(at, 4)
|
||||
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Fill with a non-zero byte: a zero-filled image makes any needle ending
|
||||
/// in `0x00` match by accident, which is not what this is testing.
|
||||
fn ram_with(needle: &[u8], at: usize) -> Vec<u8> {
|
||||
let mut v = vec![0xaau8; EE_SIZE];
|
||||
v[at..at + needle.len()].copy_from_slice(needle);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_32mib_image_is_accepted() {
|
||||
EeRam::new(&[0u8; EE_SIZE]).expect("32 MiB is exactly right");
|
||||
assert!(EeRam::new(&[0u8; EE_SIZE - 1]).is_err());
|
||||
assert!(EeRam::new(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_reports_every_occurrence() {
|
||||
let v = ram_with(&[0xde, 0xad, 0xbe, 0xef], 0x1000);
|
||||
let ram = EeRam::new(&v).unwrap();
|
||||
assert_eq!(ram.find(&[0xde, 0xad, 0xbe, 0xef]), Some(0x1000));
|
||||
assert!(ram.find(&[0xde, 0xad, 0xbe, 0xef, 0x00]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_or_oversized_needle_finds_nothing() {
|
||||
let v = vec![7u8; EE_SIZE];
|
||||
let ram = EeRam::new(&v).unwrap();
|
||||
assert!(ram.find(&[]).is_none());
|
||||
// Longer than the image cannot occur inside it, however uniform the
|
||||
// image is.
|
||||
let too_long = vec![7u8; EE_SIZE + 1];
|
||||
assert!(ram.find(&too_long).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_needle_at_the_very_end_is_found() {
|
||||
let mut v = vec![0u8; EE_SIZE];
|
||||
let n = [1u8, 2, 3, 4];
|
||||
v[EE_SIZE - 4..].copy_from_slice(&n);
|
||||
let ram = EeRam::new(&v).unwrap();
|
||||
assert_eq!(ram.find(&n), Some(EE_SIZE - 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! `gc-trace` — the PCSX2 ground-truth harness.
|
||||
//!
|
||||
//! This crate is a **dev tool**. It never ships and no shipped crate depends
|
||||
//! on it; the dependency direction is the other way round (`tools/` may read
|
||||
//! what the engine computes). Its job is the one the rest of the suite cannot
|
||||
//! do: compare what the real game does against what we compute, from the same
|
||||
//! disc data.
|
||||
//!
|
||||
//! Inputs, in order of convenience:
|
||||
//!
|
||||
//! * **PINE** — the live emulator, over its unix socket. Needs PCSX2 running
|
||||
//! with PINE enabled. No savestate, no files.
|
||||
//! * **a PCSX2 savestate** (`.p2s`) — a zip with zstd/deflate entries holding
|
||||
//! `eeMemory.bin` and friends. Repeatable input: the file can be kept,
|
||||
//! but it is personal data and is never committed.
|
||||
//! * **a raw EE RAM dump** — 32 MiB, whatever produced it.
|
||||
//!
|
||||
//! The one comparison that exists so far is
|
||||
//! [`compare-tfrag-light`](tfrag_cmp::compare): the game's lit per-vertex
|
||||
//! tfrag colours in EE RAM against [`gc_engine::tfrag_light::light_tfrag`] over
|
||||
//! the same disc data. See `docs/plan/pcsx2-verification.md`.
|
||||
//!
|
||||
//! **Findings reach tests only as numbers.** A test never reads a savestate, a
|
||||
//! dump or a live emulator; when a comparison becomes a regression, distil the
|
||||
//! facts it needs into a small fixture and test against that.
|
||||
|
||||
pub mod ee;
|
||||
pub mod p2s;
|
||||
pub mod pine;
|
||||
pub mod tfrag_cmp;
|
||||
pub mod zip;
|
||||
|
||||
pub use ee::EeRam;
|
||||
pub use p2s::Savestate;
|
||||
pub use tfrag_cmp::{compare, print_report, LevelInputs, Report};
|
||||
@@ -0,0 +1,344 @@
|
||||
//! The `gc-trace` command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! gc-trace compare-tfrag-light <unpacked-dir> --level N <src>
|
||||
//! gc-trace info <src>
|
||||
//! gc-trace dump-ee <src> [--out FILE]
|
||||
//! gc-trace find <src> --bytes HEX
|
||||
//! gc-trace read <src> --addr HEX --len N
|
||||
//! gc-trace pine-info
|
||||
//! gc-trace pine-savestate SLOT
|
||||
//! ```
|
||||
//!
|
||||
//! `<src>` is where the memory comes from, and it is one of:
|
||||
//!
|
||||
//! | Flag | Reads |
|
||||
//! |---|---|
|
||||
//! | `--state FILE` | a PCSX2 `.p2s` savestate |
|
||||
//! | `--state latest` | the newest state in PCSX2's folder |
|
||||
//! | `--ee FILE` | a raw 32 MiB EE RAM dump |
|
||||
//! | `--pine [slot]` | the running emulator over PINE |
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use gc_trace::ee::EeRam;
|
||||
use gc_trace::{compare, LevelInputs};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&args) {
|
||||
Ok(code) => std::process::exit(code),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the process exit code.
|
||||
fn run(args: &[String]) -> Result<i32> {
|
||||
let Some(cmd) = args.first() else {
|
||||
print!("{USAGE}");
|
||||
return Ok(0);
|
||||
};
|
||||
match cmd.as_str() {
|
||||
"info" => {
|
||||
let src = Source::parse(&args[1..])?;
|
||||
info(&src)
|
||||
}
|
||||
"dump-ee" => {
|
||||
let (src, rest) = Source::parse_with_rest(&args[1..])?;
|
||||
let out = flag(&rest, "--out").unwrap_or_else(|| "ee.bin".into());
|
||||
dump_ee(&src, &out)
|
||||
}
|
||||
"find" => {
|
||||
let (src, rest) = Source::parse_with_rest(&args[1..])?;
|
||||
let hex = require(&rest, "--bytes")?;
|
||||
find(&src, &hex)
|
||||
}
|
||||
"read" => {
|
||||
let (src, rest) = Source::parse_with_rest(&args[1..])?;
|
||||
let addr = require(&rest, "--addr")?;
|
||||
let len = require(&rest, "--len")?;
|
||||
read(&src, &addr, &len)
|
||||
}
|
||||
"compare-tfrag-light" => {
|
||||
let (src, rest) = Source::parse_with_rest(&args[1..])?;
|
||||
let dir = rest
|
||||
.first()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("which unpacked level directory?"))?;
|
||||
let level: usize = flag(&rest, "--level")
|
||||
.unwrap_or_else(|| "0".into())
|
||||
.parse()
|
||||
.context("--level")?;
|
||||
compare_tfrag_light(&dir, level, &src)
|
||||
}
|
||||
"pine-info" => pine_info(),
|
||||
"pine-savestate" => {
|
||||
let slot: u8 = args
|
||||
.get(1)
|
||||
.ok_or_else(|| anyhow!("which slot?"))?
|
||||
.parse()
|
||||
.context("slot")?;
|
||||
pine_savestate(slot)
|
||||
}
|
||||
_ => {
|
||||
print!("{USAGE}");
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: gc-trace <command> ...
|
||||
compare-tfrag-light <unpacked-dir> [--level N] <src>
|
||||
info <src>
|
||||
dump-ee <src> [--out FILE]
|
||||
find <src> --bytes HEX
|
||||
read <src> --addr HEX --len N
|
||||
pine-info
|
||||
pine-savestate SLOT
|
||||
|
||||
<src> is one of:
|
||||
--state FILE a PCSX2 .p2s savestate
|
||||
--state latest the newest state in PCSX2's folder
|
||||
--ee FILE a raw 32 MiB EE RAM dump
|
||||
--pine [slot] the running emulator over PINE
|
||||
";
|
||||
|
||||
/// Where a memory image comes from.
|
||||
enum Source {
|
||||
State(PathBuf),
|
||||
Latest,
|
||||
Raw(PathBuf),
|
||||
Pine(u32),
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The first three arguments after the command.
|
||||
fn parse(args: &[String]) -> Result<Source> {
|
||||
Ok(Self::parse_with_rest(args)?.0)
|
||||
}
|
||||
|
||||
/// The source flag, and everything else.
|
||||
///
|
||||
/// The flag may sit anywhere: the usage line puts the level directory
|
||||
/// first, which is the order people type, so a first-argument-only reader
|
||||
/// would reject its own documented invocation. Whatever is left after the
|
||||
/// flag and its value is passed back for the caller's own flags.
|
||||
fn parse_with_rest(args: &[String]) -> Result<(Source, Vec<String>)> {
|
||||
let Some(at) = args
|
||||
.iter()
|
||||
.position(|a| matches!(a.as_str(), "--state" | "--ee" | "--pine"))
|
||||
else {
|
||||
return Err(anyhow!("no source; pass --state FILE, --ee FILE or --pine"));
|
||||
};
|
||||
let (flag, tail) = (args[at].as_str(), &args[at + 1..]);
|
||||
let (src, used) = match flag {
|
||||
"--state" => {
|
||||
let v = tail
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("--state needs a path or `latest`"))?;
|
||||
let src = if v == "latest" {
|
||||
Source::Latest
|
||||
} else {
|
||||
Source::State(PathBuf::from(v))
|
||||
};
|
||||
(src, 1)
|
||||
}
|
||||
"--ee" => {
|
||||
let v = tail.first().ok_or_else(|| anyhow!("--ee needs a path"))?;
|
||||
(Source::Raw(PathBuf::from(v)), 1)
|
||||
}
|
||||
// `--pine [slot]`: a following slot number is consumed, and
|
||||
// anything else -- a directory, another flag -- is not.
|
||||
_ => {
|
||||
let slot = match tail.first() {
|
||||
Some(s) if s.parse::<u32>().is_ok() => s.parse()?,
|
||||
_ => gc_trace::pine::DEFAULT_SLOT,
|
||||
};
|
||||
let used = usize::from(tail.first().is_some_and(|s| s.parse::<u32>().is_ok()));
|
||||
(Source::Pine(slot), used)
|
||||
}
|
||||
};
|
||||
let mut rest: Vec<String> = args[..at].to_vec();
|
||||
rest.extend_from_slice(&tail[used..]);
|
||||
Ok((src, rest))
|
||||
}
|
||||
|
||||
/// Load the RAM image this source names.
|
||||
fn load(&self) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
Source::State(p) => gc_trace::Savestate::open(p)
|
||||
.and_then(|s| s.ee_ram_owned())
|
||||
.with_context(|| format!("savestate {}", p.display())),
|
||||
Source::Latest => {
|
||||
let p = latest_state()?;
|
||||
let bytes = gc_trace::Savestate::open(&p).and_then(|s| s.ee_ram_owned());
|
||||
bytes.with_context(|| format!("savestate {} (latest)", p.display()))
|
||||
}
|
||||
Source::Raw(p) => {
|
||||
let b = std::fs::read(p).with_context(|| format!("read {}", p.display()))?;
|
||||
Ok(b)
|
||||
}
|
||||
Source::Pine(slot) => gc_trace::pine::Pine::connect_slot(*slot)
|
||||
.and_then(|p| p.dump_ee())
|
||||
.context("PINE dump"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_state() -> Result<PathBuf> {
|
||||
let dir = pcsx2_state_dir();
|
||||
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().map(|e| e == "p2s").unwrap_or(false) {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(t) = meta.modified() {
|
||||
if newest.as_ref().is_none_or(|(nt, _)| t > *nt) {
|
||||
newest = Some((t, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
.map(|(_, p)| p)
|
||||
.ok_or_else(|| anyhow!("no .p2s savestates in {}", dir.display()))
|
||||
}
|
||||
|
||||
/// PCSX2's savestate folder, per platform.
|
||||
fn pcsx2_state_dir() -> PathBuf {
|
||||
if cfg!(target_os = "macos") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join("Library/Application Support/PCSX2/sstates")
|
||||
} else {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".config")
|
||||
});
|
||||
base.join("PCSX2/sstates")
|
||||
}
|
||||
}
|
||||
|
||||
fn info(src: &Source) -> Result<i32> {
|
||||
match src {
|
||||
Source::State(p) => info_state(p)?,
|
||||
Source::Latest => info_state(&latest_state()?)?,
|
||||
Source::Raw(_) => {
|
||||
let bytes = src.load()?;
|
||||
let ram = EeRam::new(&bytes)?;
|
||||
println!("raw EE RAM image, {} bytes", ram.bytes().len());
|
||||
}
|
||||
Source::Pine(slot) => {
|
||||
let pine = gc_trace::pine::Pine::connect_slot(*slot)?;
|
||||
println!("PINE: version {}", pine.version()?);
|
||||
println!("PINE: title {}", pine.title()?);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn info_state(p: &std::path::Path) -> Result<()> {
|
||||
let state = gc_trace::Savestate::open(p)?;
|
||||
println!(
|
||||
"{}: v{:#010x} build {}",
|
||||
p.display(),
|
||||
state.version.unwrap_or(0),
|
||||
state.build
|
||||
);
|
||||
for e in state.entries() {
|
||||
println!(" {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dump_ee(src: &Source, out: &str) -> Result<i32> {
|
||||
let bytes = src.load()?;
|
||||
let ram = EeRam::new(&bytes)?;
|
||||
std::fs::write(out, ram.bytes()).with_context(|| format!("write {out}"))?;
|
||||
println!("wrote {} bytes to {out}", ram.bytes().len());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn find(src: &Source, hex: &str) -> Result<i32> {
|
||||
let bytes = src.load()?;
|
||||
let ram = EeRam::new(&bytes)?;
|
||||
let needle = parse_hex(hex)?;
|
||||
for at in ram.find_all(&needle) {
|
||||
println!("{at:#x}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn read(src: &Source, addr_hex: &str, len_hex: &str) -> Result<i32> {
|
||||
let bytes = src.load()?;
|
||||
let ram = EeRam::new(&bytes)?;
|
||||
let addr = usize::from_str_radix(addr_hex.trim_start_matches("0x"), 16)?;
|
||||
let len = usize::from_str_radix(len_hex.trim_start_matches("0x"), 16)?;
|
||||
let slice = ram
|
||||
.slice(addr, len)
|
||||
.ok_or_else(|| anyhow!("{addr:#x}+{len:#x} runs past the RAM image"))?;
|
||||
for (i, row) in slice.chunks(16).enumerate() {
|
||||
let hex: Vec<String> = row.iter().map(|b| format!("{b:02x}")).collect();
|
||||
println!("{:08x} {}", addr + i * 16, hex.join(" "));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn compare_tfrag_light(dir: &std::path::Path, level: usize, src: &Source) -> Result<i32> {
|
||||
let inputs = LevelInputs::load(dir, level)?;
|
||||
let bytes = src.load()?;
|
||||
let ram = EeRam::new(&bytes)?;
|
||||
let report = compare(&inputs, level, &ram)?;
|
||||
let mut text = String::new();
|
||||
gc_trace::print_report(&report, &mut text)?;
|
||||
println!("{text}");
|
||||
Ok(report.exit_code())
|
||||
}
|
||||
|
||||
fn pine_info() -> Result<i32> {
|
||||
let pine = gc_trace::pine::Pine::connect()?;
|
||||
println!("version: {}", pine.version()?);
|
||||
println!("title: {}", pine.title()?);
|
||||
println!(
|
||||
"PINE is up on {}",
|
||||
gc_trace::pine::socket_path(gc_trace::pine::DEFAULT_SLOT).display()
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn pine_savestate(slot: u8) -> Result<i32> {
|
||||
let pine = gc_trace::pine::Pine::connect()?;
|
||||
pine.save_state(slot)?;
|
||||
println!("savestate requested for slot {slot}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Parse `hex` bytes, ignoring whitespace.
|
||||
fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if compact.len() % 2 != 0 {
|
||||
return Err(anyhow!("hex string has an odd length"));
|
||||
}
|
||||
(0..compact.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&compact[i..i + 2], 16).map_err(Into::into))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up `--flag VALUE` in the rest.
|
||||
fn flag(args: &[String], name: &str) -> Option<String> {
|
||||
let at = args.iter().position(|a| a == name)?;
|
||||
args.get(at + 1).cloned()
|
||||
}
|
||||
|
||||
fn require(args: &[String], name: &str) -> Result<String> {
|
||||
flag(args, name).ok_or_else(|| anyhow!("missing {name}"))
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! A PCSX2 savestate (`.p2s`), read for its EE RAM.
|
||||
//!
|
||||
//! The format is a zip archive written by libzip, whose entries are the
|
||||
//! emulator's state: `eeMemory.bin` is main RAM, and there is a version id
|
||||
//! entry that says which PCSX2 wrote it. All of this is read from the file,
|
||||
//! never assumed: the version check and the size check are what stop a
|
||||
//! plausible-looking wrong read — a partial file, a state from a much older
|
||||
//! PCSX2 — from being compared against as though it were the game's RAM.
|
||||
//!
|
||||
//! Compression is whatever PCSX2 chose (zstd by default, deflate or stored if
|
||||
//! the user configured otherwise), which is [`crate::zip`]'s job.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::zip::Zip;
|
||||
|
||||
/// The savestate version PCSX2 v2.x writes; the high 16 bits are the load
|
||||
/// gate, the low 16 the patch level.
|
||||
const SAVE_VERSION: u32 = 0x9A59_0000;
|
||||
|
||||
const VERSION_ID: &str = "PCSX2 Savestate Version.id";
|
||||
const EE_MEMORY: &str = "eeMemory.bin";
|
||||
|
||||
/// A loaded savestate.
|
||||
pub struct Savestate {
|
||||
bytes: Vec<u8>,
|
||||
zip: Zip,
|
||||
/// The version word from the version-id entry, if present.
|
||||
pub version: Option<u32>,
|
||||
/// The version string that follows it.
|
||||
pub build: String,
|
||||
}
|
||||
|
||||
impl Savestate {
|
||||
/// Open and fully validate a savestate file.
|
||||
pub fn open(path: &Path) -> Result<Savestate> {
|
||||
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
let state = Self::from_bytes(bytes)?;
|
||||
state
|
||||
.validate()
|
||||
.with_context(|| format!("{}: not a supported savestate", path.display()))?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// The version gate. A state whose version entry is missing or from a
|
||||
/// different major line may have a different entry layout, so stop here
|
||||
/// rather than guess which entry is the RAM.
|
||||
///
|
||||
/// Exposed so a fixture is checked the same way a real file is; `open`
|
||||
/// calls exactly this.
|
||||
fn validate(&self) -> Result<()> {
|
||||
let v = self
|
||||
.version
|
||||
.ok_or_else(|| anyhow!("no {VERSION_ID} entry; not a PCSX2 v2.x savestate"))?;
|
||||
if v & 0xFFFF_0000 != SAVE_VERSION {
|
||||
return Err(anyhow!(
|
||||
"savestate version {v:#010x}, this reader handles {SAVE_VERSION:#010x} (PCSX2 v2.8.x)"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build from an in-memory image.
|
||||
pub fn from_bytes(bytes: Vec<u8>) -> Result<Savestate> {
|
||||
let zip = Zip::parse(&bytes)?;
|
||||
let (version, build) = match zip.find(VERSION_ID) {
|
||||
Some(_) => {
|
||||
let id = zip.read(&bytes, VERSION_ID)?;
|
||||
if id.len() < 4 {
|
||||
return Err(anyhow!("version id entry is {} bytes", id.len()));
|
||||
}
|
||||
// The build string is read out of the first 32 bytes after the
|
||||
// version word. A longer entry carries more after it, which is
|
||||
// not read: only the version is load-bearing here.
|
||||
(
|
||||
Some(u32::from_le_bytes(id[..4].try_into().unwrap())),
|
||||
String::from_utf8_lossy(&id[4..36.min(id.len())])
|
||||
.trim_end_matches('\0')
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
None => (None, String::new()),
|
||||
};
|
||||
Ok(Savestate {
|
||||
bytes,
|
||||
zip,
|
||||
version,
|
||||
build,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entry names, which is also a cheap sanity check on what is here.
|
||||
pub fn entries(&self) -> Vec<&str> {
|
||||
self.zip.entries().iter().map(|e| e.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// One decompressed entry.
|
||||
pub fn entry(&self, name: &str) -> Result<Vec<u8>> {
|
||||
self.zip.read(&self.bytes, name)
|
||||
}
|
||||
|
||||
/// The decompressed `eeMemory.bin` as an owned buffer.
|
||||
///
|
||||
/// Owned rather than borrowed: the caller wants a stable `EeRam` over the
|
||||
/// bytes, and the zip entry's payload cannot outlive this call anyway.
|
||||
pub fn ee_ram_owned(&self) -> Result<Vec<u8>> {
|
||||
let ram = self.entry(EE_MEMORY)?;
|
||||
Ok(ram)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::zip::LOCAL_HEADER;
|
||||
|
||||
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for (name, payload) in entries {
|
||||
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // stored
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crate::zip::crc32(payload).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn version_id() -> Vec<u8> {
|
||||
let mut v = SAVE_VERSION.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(b"v2.8.2");
|
||||
v.resize(36, 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_round_trips_its_ram() {
|
||||
let ram = vec![0x5au8; 16];
|
||||
let z = build_zip(&[(VERSION_ID, &version_id()), (EE_MEMORY, &ram)]);
|
||||
let state = Savestate::from_bytes(z).expect("should read");
|
||||
assert_eq!(state.version, Some(SAVE_VERSION));
|
||||
assert!(state.build.starts_with("v2.8"));
|
||||
assert_eq!(state.entry(EE_MEMORY).unwrap(), ram);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_savestate_version_is_an_error() {
|
||||
let bad = 0x9A3B_0000u32; // a future major line
|
||||
let mut v = bad.to_le_bytes().to_vec();
|
||||
v.resize(36, 0);
|
||||
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
|
||||
let state = Savestate::from_bytes(z).expect("reads the archive");
|
||||
assert_eq!(state.version, Some(bad));
|
||||
let err = state.validate().unwrap_err().to_string();
|
||||
assert!(err.contains("version"), "got {err}");
|
||||
// The gate reads the high half only: a newer patch level of the same
|
||||
// major line is a state this reader handles.
|
||||
let patched = 0x9A59_0001u32;
|
||||
let mut v = patched.to_le_bytes().to_vec();
|
||||
v.resize(36, 0);
|
||||
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
|
||||
assert!(Savestate::from_bytes(z).unwrap().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_version_entry_is_reported_not_guessed() {
|
||||
let z = build_zip(&[(EE_MEMORY, &[0u8; 8])]);
|
||||
let state = Savestate::from_bytes(z).unwrap();
|
||||
assert_eq!(state.version, None);
|
||||
assert!(state.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! PINE: PCSX2's live debug protocol.
|
||||
//!
|
||||
//! PCSX2 exposes a unix socket (`$XDG_RUNTIME_DIR/pcsx2.sock` on Linux,
|
||||
//! `$TMPDIR` on macOS, `/tmp/pcsx2.sock` otherwise, `.N` appended for a
|
||||
//! non-default slot) that answers framed messages with the emulator's own
|
||||
//! memory. The framing is simple and versions of it appear in several
|
||||
//! clients, so it is spelled out from the v2.8.2 sources:
|
||||
//!
|
||||
//! ```text
|
||||
//! request: u32 length | command bytes, command after command
|
||||
//! reply: u32 length | u8 status | one reply per command, in order
|
||||
//! ```
|
||||
//!
|
||||
//! A read is `u8 MsgRead64` followed by a `u32` address and answers with the
|
||||
//! eight bytes at that address; commands batch, so a 32 MiB dump is 84
|
||||
//! round-trips of 50,000 reads each, which is the reply-size ceiling.
|
||||
//!
|
||||
//! Live reads mean the input is whatever the emulator is doing *right now*.
|
||||
//! That makes this mode excellent for exploring and poor for a repeatable
|
||||
//! test, which is why the harness also reads savestates (`crate::p2s`).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// PCSX2's default slot.
|
||||
pub const DEFAULT_SLOT: u32 = 28011;
|
||||
|
||||
const MSG_READ64: u8 = 3;
|
||||
const MSG_VERSION: u8 = 8;
|
||||
const MSG_SAVE_STATE: u8 = 9;
|
||||
const MSG_TITLE: u8 = 0xB;
|
||||
const MSG_STATUS: u8 = 0xF;
|
||||
|
||||
/// Reads per batched message. The reply budget is 450,000 bytes, so 50,000
|
||||
/// eight-byte reads (400,000 bytes of payload plus framing) is the largest
|
||||
/// batch that cannot overflow it.
|
||||
const BATCH: usize = 50_000;
|
||||
|
||||
/// The emulator's state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Running,
|
||||
Paused,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// A connected PINE session.
|
||||
pub struct Pine {
|
||||
stream: UnixStream,
|
||||
}
|
||||
|
||||
impl Pine {
|
||||
/// Connect to the socket for `slot`.
|
||||
pub fn connect_slot(slot: u32) -> Result<Pine> {
|
||||
let path = socket_path(slot);
|
||||
let stream = UnixStream::connect(&path).with_context(|| {
|
||||
format!(
|
||||
"cannot connect to PINE at {}\n\
|
||||
is PCSX2 running with PINE enabled (Settings > Advanced)?",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Pine { stream })
|
||||
}
|
||||
|
||||
/// Connect at the default slot.
|
||||
pub fn connect() -> Result<Pine> {
|
||||
Self::connect_slot(DEFAULT_SLOT)
|
||||
}
|
||||
|
||||
/// One framed round-trip; `payload` is the concatenated commands.
|
||||
fn roundtrip(&self, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut msg = Vec::with_capacity(4 + payload.len());
|
||||
msg.extend_from_slice(&((4 + payload.len()) as u32).to_le_bytes());
|
||||
msg.extend_from_slice(payload);
|
||||
let mut stream = &self.stream;
|
||||
stream.write_all(&msg).context("PINE write")?;
|
||||
let mut header = [0u8; 5];
|
||||
stream
|
||||
.read_exact(&mut header)
|
||||
.context("PINE reply header")?;
|
||||
let size = u32::from_le_bytes(header[..4].try_into().unwrap()) as usize;
|
||||
let status = header[4];
|
||||
if status != 0 {
|
||||
return Err(anyhow!("PINE refused the message (status {status:#x})"));
|
||||
}
|
||||
let mut body = vec![0u8; size - 5];
|
||||
stream.read_exact(&mut body).context("PINE reply body")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The emulator's build string, which is how the protocol is discovered.
|
||||
pub fn version(&self) -> Result<String> {
|
||||
let body = self.roundtrip(&[MSG_VERSION])?;
|
||||
Ok(String::from_utf8_lossy(&body)
|
||||
.trim_end_matches('\0')
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub fn title(&self) -> Result<String> {
|
||||
let body = self.roundtrip(&[MSG_TITLE])?;
|
||||
Ok(String::from_utf8_lossy(&body)
|
||||
.trim_end_matches('\0')
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Read `count` u64s starting at `addr`.
|
||||
///
|
||||
/// Addresses are physical: EE RAM is 0..32 MiB, so a physical address is
|
||||
/// what this takes and what the game stores once its pointers are
|
||||
/// relocated.
|
||||
pub fn read64(&self, addr: u32, count: usize) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(count * 8);
|
||||
let mut done = 0usize;
|
||||
while done < count {
|
||||
let n = (count - done).min(BATCH);
|
||||
let mut payload = Vec::with_capacity(n * 5);
|
||||
for i in 0..n {
|
||||
payload.push(MSG_READ64);
|
||||
payload.extend_from_slice(&(addr + (done + i) as u32 * 8).to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&self.roundtrip(&payload)?);
|
||||
done += n;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// All of EE RAM, which is what the comparisons want.
|
||||
pub fn dump_ee(&self) -> Result<Vec<u8>> {
|
||||
self.read64(0, crate::ee::EE_SIZE / 8)
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Result<Status> {
|
||||
let body = self.roundtrip(&[MSG_STATUS])?;
|
||||
match u32::from_le_bytes(body[..4].try_into().unwrap()) {
|
||||
0 => Ok(Status::Running),
|
||||
1 => Ok(Status::Paused),
|
||||
_ => Ok(Status::Shutdown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask PCSX2 to write a savestate to `slot`. Saving happens on the
|
||||
/// emulator's own thread, so the state lands shortly after this returns.
|
||||
pub fn save_state(&self, slot: u8) -> Result<()> {
|
||||
self.roundtrip(&[MSG_SAVE_STATE, slot])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket path for a slot, following PCSX2's own resolution order.
|
||||
pub fn socket_path(slot: u32) -> PathBuf {
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.or_else(|| std::env::var_os("TMPDIR"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
let mut path = base.join("pcsx2.sock");
|
||||
if slot != DEFAULT_SLOT {
|
||||
path = PathBuf::from(format!("{}.{slot}", path.display()));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_socket_path_has_the_slot_suffix_only_when_needed() {
|
||||
let path = socket_path(DEFAULT_SLOT);
|
||||
assert!(path.ends_with("pcsx2.sock"));
|
||||
let other = socket_path(28012);
|
||||
assert!(other.ends_with("pcsx2.sock.28012"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_framed_length_then_status_then_body() {
|
||||
// A failed command is `05 000000 FF` on the wire; the parser reads
|
||||
// exactly that shape, so check it by constructing the reply the same
|
||||
// way the reader does.
|
||||
let mut reply: Vec<u8> = 0x05u32.to_le_bytes().to_vec();
|
||||
reply.push(0xFF);
|
||||
assert_eq!(reply.len(), 5);
|
||||
assert_eq!(u32::from_le_bytes(reply[..4].try_into().unwrap()), 5);
|
||||
assert_eq!(reply[4], 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
//! The tfrag lighting comparison: the game's own bytes against ours.
|
||||
//!
|
||||
//! `LightTfrags` overwrites every tfrag's RGBA block in EE RAM with its lit
|
||||
//! per-vertex colours, so after a level loads the RAM holds the answer. The
|
||||
//! comparison is:
|
||||
//!
|
||||
//! 1. **Locate** the tfrags block in RAM by content, never by a fixed
|
||||
//! address — the load address differs per level and per run. The tfrag-0
|
||||
//! bounding sphere is the search key; a candidate is only accepted when
|
||||
//! *every* header matches the disc on the bytes the game leaves alone
|
||||
//! (all but the relocated `data` pointer and the runtime occlusion
|
||||
//! index) and the `data` pointers are relocated exactly as
|
||||
//! `table + disc_data`.
|
||||
//! 2. **Locate the light bank** the same way: the bytes of the level's
|
||||
//! gameplay light section, searched in RAM. It is compared against the
|
||||
//! disc's so a parse error and a maths error are distinguishable.
|
||||
//! 3. **Run the pass** over the same disc data with
|
||||
//! [`gc_engine::tfrag_light::light_tfrag`].
|
||||
//! 4. **Diff** per channel, per tfrag, bucketed by vertex class so a
|
||||
//! mismatch in the blend path (which is the only path `rsqrt` touches)
|
||||
//! separates from one in the plain path.
|
||||
//!
|
||||
//! The pass-ran check is the number of vertices whose RAM colour still equals
|
||||
//! the disc's unlit placeholder. If that is near the whole level, the pass
|
||||
//! has not run yet and the comparison means nothing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use gc_engine::tfrag_light::{light_tfrag, light_vertex_terms, vertex_normal_bits, VertexTerms};
|
||||
use gc_formats::level_core::LevelCoreHeader;
|
||||
use gc_formats::tfrag::{parse_tfrag, TfragHeader, TfragsBlock, TFRAG_HEADER_SIZE};
|
||||
use gc_formats::tfrag_light::{LightBank, NormalTable};
|
||||
|
||||
use crate::ee::EeRam;
|
||||
|
||||
/// How many mismatching vertices keep their terms in the report. Enough to
|
||||
/// read a pattern by hand, few enough that the report stays readable.
|
||||
const BREAKDOWNS: usize = 6;
|
||||
|
||||
/// Result of one comparison, everything a report needs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Report {
|
||||
pub level: usize,
|
||||
/// RAM address of the tfrags block, its header table, and the tfrag count.
|
||||
pub block_base: usize,
|
||||
pub table: usize,
|
||||
pub tfrag_count: usize,
|
||||
pub headers_matched: usize,
|
||||
pub pointers_relocated: usize,
|
||||
/// RAM address of the directional light bank, when found.
|
||||
pub bank_ram: Option<usize>,
|
||||
/// Whether the bank was found in RAM **byte-identical to the disc's**. It
|
||||
/// is a content hit, so a found bank is by construction the disc's; the
|
||||
/// flag exists to keep the not-found case out of that claim, because then
|
||||
/// the comparison below runs on the disc's bank with nothing from the
|
||||
/// game to confirm it.
|
||||
pub bank_is_disc: bool,
|
||||
pub vertices_compared: usize,
|
||||
pub bytes_compared: usize,
|
||||
pub bytes_equal: usize,
|
||||
/// Vertices whose RAM colour still equals the disc's unlit placeholder.
|
||||
pub placeholder_equal: usize,
|
||||
/// Per-vertex-class tallies: `single-set`, `blended`, and `no-set` for
|
||||
/// vertices whose `select` names a light set the disc's bank does not have
|
||||
/// -- runtime lights, which a level-load comparison cannot see.
|
||||
pub classes: BTreeMap<&'static str, ClassStat>,
|
||||
/// `ours - ram` per channel, in order.
|
||||
pub histogram: BTreeMap<i32, usize>,
|
||||
/// The first few differing vertices, for reading by hand.
|
||||
pub mismatches: Vec<Mismatch>,
|
||||
/// The pass's terms for the first few mismatching vertices. A colour
|
||||
/// mismatch alone cannot be attributed, and the whole point of the
|
||||
/// comparison is to say *which* term the game disagrees with.
|
||||
pub breakdowns: Vec<Breakdown>,
|
||||
}
|
||||
|
||||
/// One mismatching vertex with its terms, so the difference can be read as
|
||||
/// `ours` versus `ram` per term rather than per byte.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Breakdown {
|
||||
pub tfrag: usize,
|
||||
pub entry: usize,
|
||||
/// `None` when the pass found no light set for the vertex's `select` --
|
||||
/// which is itself the finding, so it is recorded rather than skipped.
|
||||
pub terms: Option<VertexTerms>,
|
||||
/// The record's `select`, always available even when the terms are not.
|
||||
pub select: u16,
|
||||
/// The record's base colour, PEXT5-expanded: what the game would write if
|
||||
/// the light contributed nothing.
|
||||
pub base: [u8; 4],
|
||||
/// The disc's stored RGBA for the entry, which is the unlit placeholder.
|
||||
pub disc: [u8; 4],
|
||||
pub ram: [u8; 4],
|
||||
}
|
||||
|
||||
/// Totals for one vertex class.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ClassStat {
|
||||
pub vertices: usize,
|
||||
pub equal: usize,
|
||||
}
|
||||
|
||||
/// One differing vertex byte.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Mismatch {
|
||||
pub tfrag: usize,
|
||||
pub entry: usize,
|
||||
pub channel: usize,
|
||||
pub ours: i32,
|
||||
pub ram: i32,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// 0 = every byte equal, 2 = mismatches.
|
||||
pub fn exit_code(&self) -> i32 {
|
||||
if self.bytes_equal == self.bytes_compared {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the comparison needs from one unpacked level.
|
||||
pub struct LevelInputs {
|
||||
pub core_data: Vec<u8>,
|
||||
pub core_index: Vec<u8>,
|
||||
pub gameplay: Vec<u8>,
|
||||
pub overlay: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LevelInputs {
|
||||
pub fn load(dir: &Path, level: usize) -> Result<LevelInputs> {
|
||||
let read = |name: &str| {
|
||||
std::fs::read(dir.join(format!("level{level}")).join(name))
|
||||
.with_context(|| format!("level{level}/{name}"))
|
||||
};
|
||||
Ok(LevelInputs {
|
||||
core_data: read("core_data.bin")?,
|
||||
core_index: read("core_index.bin")?,
|
||||
gameplay: read("gameplay.bin")?,
|
||||
overlay: read("overlay.bin")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare the tfrag lighting of one level against a RAM image.
|
||||
pub fn compare(inputs: &LevelInputs, level: usize, ram: &EeRam) -> Result<Report> {
|
||||
// Parsed for its validation, not for a field: a core index that does not
|
||||
// parse cannot name the tfrags region the rest of this reads.
|
||||
LevelCoreHeader::parse(&inputs.core_index)?;
|
||||
let tfrags_ofs = i32_at(&inputs.core_index, 0x08)
|
||||
.ok_or_else(|| anyhow!("core index is too short for the tfrags offset"))?;
|
||||
let region_end = [0x0c_usize, 0x10, 0x14]
|
||||
.iter()
|
||||
.filter_map(|&o| i32_at(&inputs.core_index, o))
|
||||
.filter(|&v| v > tfrags_ofs)
|
||||
.min()
|
||||
.unwrap_or(inputs.core_data.len() as i32);
|
||||
let region = &inputs.core_data[tfrags_ofs as usize..region_end as usize];
|
||||
let block = TfragsBlock::parse(region)?;
|
||||
let table_off = block.table_offset as usize;
|
||||
let count = block.tfrag_count as usize;
|
||||
if count == 0 {
|
||||
anyhow::bail!("level{level} has no tfrags");
|
||||
}
|
||||
|
||||
let region_len = region.len();
|
||||
let block_base = locate_block(region, ram, table_off, count)?;
|
||||
|
||||
// The RAM table, then every header against the disc on the invariant mask.
|
||||
let table = block_base + table_off;
|
||||
let mut headers_matched = 0usize;
|
||||
let mut pointers_relocated = 0usize;
|
||||
for i in 0..count {
|
||||
let at = i * TFRAG_HEADER_SIZE;
|
||||
let disc_h = ®ion[table_off + at..table_off + at + TFRAG_HEADER_SIZE];
|
||||
let ram_h = ram
|
||||
.slice(table + at, TFRAG_HEADER_SIZE)
|
||||
.ok_or_else(|| anyhow!("tfrag header {i} runs past the RAM image"))?;
|
||||
if invariant_equal(disc_h, ram_h) {
|
||||
headers_matched += 1;
|
||||
}
|
||||
// data is relocated to an absolute pointer into the RAM copy.
|
||||
let disc_data = i32_at(disc_h, 0x10).unwrap_or(0);
|
||||
let ram_data = ram.i32(table + at + 0x10).unwrap_or(0);
|
||||
if ram_data as usize == block_base + table_off + disc_data as usize {
|
||||
pointers_relocated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The bank: search RAM for the gameplay's light section, byte for byte.
|
||||
// What is searched for is the section's own bytes, so a hit *is* the
|
||||
// comparison -- a bank that is in RAM by content is the disc's bank, and
|
||||
// the only other outcome is that it is not there, which is reported
|
||||
// rather than papered over.
|
||||
let disc_bank = LightBank::parse(&inputs.gameplay)?;
|
||||
let section = bank_section(&inputs.gameplay)?;
|
||||
let bank_ram = if section.is_empty() {
|
||||
None
|
||||
} else {
|
||||
ram.find(section)
|
||||
};
|
||||
|
||||
let table_normals =
|
||||
NormalTable::from_overlay(&inputs.overlay).context("the level overlay's normal table")?;
|
||||
|
||||
let mut report = Report {
|
||||
level,
|
||||
block_base,
|
||||
table,
|
||||
tfrag_count: count,
|
||||
headers_matched,
|
||||
pointers_relocated,
|
||||
bank_ram,
|
||||
bank_is_disc: bank_ram.is_some(),
|
||||
vertices_compared: 0,
|
||||
bytes_compared: 0,
|
||||
bytes_equal: 0,
|
||||
placeholder_equal: 0,
|
||||
classes: BTreeMap::new(),
|
||||
histogram: BTreeMap::new(),
|
||||
mismatches: Vec::new(),
|
||||
breakdowns: Vec::new(),
|
||||
};
|
||||
|
||||
for i in 0..count {
|
||||
let at = i * TFRAG_HEADER_SIZE;
|
||||
let disc_h =
|
||||
TfragHeader::parse(®ion[table_off + at..table_off + at + TFRAG_HEADER_SIZE])?;
|
||||
let ram_h = TfragHeader::parse(ram.slice(table + at, TFRAG_HEADER_SIZE).unwrap())?;
|
||||
let vert = disc_h.vert_count as usize;
|
||||
if vert == 0 {
|
||||
continue;
|
||||
}
|
||||
// The tfrag's own data block in RAM, bounded by the region. `ram_h.data`
|
||||
// is the already-relocated absolute pointer, and the region ends at
|
||||
// `block_base + region_len`, so the block is what is between the two.
|
||||
let ram_data = ram_h.data as usize;
|
||||
let start = ram_data
|
||||
.checked_add(0)
|
||||
.filter(|&s| s < ram.bytes().len())
|
||||
.ok_or_else(|| anyhow!("tfrag {i} data pointer {ram_data:#x} is outside RAM"))?;
|
||||
let region_end_ram = block_base + region_len;
|
||||
if start >= region_end_ram {
|
||||
anyhow::bail!("tfrag {i} data pointer {ram_data:#x} is past the tfrags region");
|
||||
}
|
||||
let len = region_end_ram - start;
|
||||
let block_ram = &ram.bytes()[start..start + len];
|
||||
let geom = parse_tfrag(block_ram, &ram_h).context("the game's copy of tfrag {i}")?;
|
||||
|
||||
// The lit colours, from our pass over this same data.
|
||||
let ours = light_tfrag(&geom, &disc_bank, &table_normals);
|
||||
|
||||
// And the RAM's, plus the disc's unlit placeholder for the same
|
||||
// entries. Only 0..vert_count mean anything: past them the block is
|
||||
// scratchpad leftovers that no vertex references.
|
||||
let rgba_ofs = ram_h.rgba_ofs as usize;
|
||||
let entries = vert.min(ours.len());
|
||||
let disc_data = disc_h.data as usize;
|
||||
for (e, ours_byte) in ours.iter().take(entries).enumerate() {
|
||||
let l = &geom.lights[e];
|
||||
let select = gem_select(&geom, e);
|
||||
let class = if select & 0xff00 == 0 {
|
||||
if disc_bank.sets.len() > (select & 0xf) as usize {
|
||||
"single-set"
|
||||
} else {
|
||||
"runtime-set"
|
||||
}
|
||||
} else if disc_bank.sets.len() > (select & 0xf) as usize {
|
||||
"blended"
|
||||
} else {
|
||||
"runtime-blend"
|
||||
};
|
||||
let stat = report.classes.entry(class).or_default();
|
||||
stat.vertices += 1;
|
||||
report.vertices_compared += 1;
|
||||
let ram_rgba = match ram.slice(start + rgba_ofs + e * 4, 4) {
|
||||
Some(b) => b,
|
||||
None => break,
|
||||
};
|
||||
let disc_rgba = region
|
||||
.get(table_off + disc_data + disc_h.rgba_ofs as usize + e * 4..)
|
||||
.and_then(|b| b.get(..4))
|
||||
.unwrap_or(&[0, 0, 0, 0]);
|
||||
let mut all_equal = true;
|
||||
for (ch, ours_byte) in ours_byte.iter().enumerate() {
|
||||
let o = *ours_byte as i32;
|
||||
let r = ram_rgba[ch] as i32;
|
||||
report.bytes_compared += 1;
|
||||
if o == r {
|
||||
report.bytes_equal += 1;
|
||||
} else {
|
||||
all_equal = false;
|
||||
*report.histogram.entry(o - r).or_default() += 1;
|
||||
if report.mismatches.len() < 32 {
|
||||
report.mismatches.push(Mismatch {
|
||||
tfrag: i,
|
||||
entry: e,
|
||||
channel: ch,
|
||||
ours: o,
|
||||
ram: r,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_equal {
|
||||
stat.equal += 1;
|
||||
} else if report.breakdowns.len() < BREAKDOWNS {
|
||||
// The terms for this vertex, as the pass computed them, so the
|
||||
// difference can be attributed to a term. A missing light set
|
||||
// is kept too: that is a finding, not a gap.
|
||||
let normal =
|
||||
vertex_normal_bits(&table_normals, l.azimuth_index(), l.elevation_index());
|
||||
let terms = light_vertex_terms(&disc_bank, normal, &geom.lights[e], l.select);
|
||||
report.breakdowns.push(Breakdown {
|
||||
tfrag: i,
|
||||
entry: e,
|
||||
terms,
|
||||
select: l.select,
|
||||
base: gc_engine::tfrag_light::pext5(l.color as u16),
|
||||
disc: [disc_rgba[0], disc_rgba[1], disc_rgba[2], disc_rgba[3]],
|
||||
ram: [ram_rgba[0], ram_rgba[1], ram_rgba[2], ram_rgba[3]],
|
||||
});
|
||||
}
|
||||
if ram_rgba == disc_rgba {
|
||||
report.placeholder_equal += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The `select` word for entry `e`, from the parsed light records.
|
||||
fn gem_select(g: &gc_formats::tfrag::TfragGeometry, e: usize) -> u16 {
|
||||
g.lights.get(e).map(|l| l.select).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The light bank's section bytes: the count word, the pad, then the sets.
|
||||
///
|
||||
/// This is what `LightBank::parse` reads, so searching RAM for it is checking
|
||||
/// the parse against the game: a hit means the game holds the same bytes the
|
||||
/// disc holds, and a miss means either the level rewrites the section or the
|
||||
/// parse does not describe what the game loaded. Both are worth knowing
|
||||
/// before any colour is compared.
|
||||
fn bank_section(gameplay: &[u8]) -> Result<&[u8]> {
|
||||
let ofs = u32_at(gameplay, 0x04)
|
||||
.ok_or_else(|| anyhow!("gameplay extent is too short for the bank offset"))?
|
||||
as usize;
|
||||
let count = i32_at(gameplay, ofs)
|
||||
.ok_or_else(|| anyhow!("the light bank offset is past the gameplay extent"))?;
|
||||
if count < 0 {
|
||||
// No lights on this level, which is not an error.
|
||||
return Ok(&[]);
|
||||
}
|
||||
let end = ofs + 0x10 + 0x40 * count as usize;
|
||||
gameplay.get(ofs..end).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"the light section runs {} bytes past the gameplay extent",
|
||||
end - gameplay.len()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the level's tfrags block in RAM.
|
||||
///
|
||||
/// The key is tfrag 0's bounding sphere: sixteen bytes of float that are
|
||||
/// unique in a 32 MiB image, but the candidate still has to prove itself —
|
||||
/// the block header must match, *every* header must match the disc on the
|
||||
/// bytes the game does not rewrite, and every `data` pointer must be the
|
||||
/// relocated form of the disc's. Anything less and the answer would be a
|
||||
/// plausible but wrong read.
|
||||
fn locate_block(region: &[u8], ram: &EeRam, table_off: usize, count: usize) -> Result<usize> {
|
||||
let key = ®ion[table_off..table_off + 16];
|
||||
let candidates = ram.find_all(key);
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for &cand in &candidates {
|
||||
if cand < table_off {
|
||||
continue;
|
||||
}
|
||||
let base = cand - table_off;
|
||||
let Some(ram_block_header) = ram.slice(base, 0x10) else {
|
||||
continue;
|
||||
};
|
||||
if ram_block_header != ®ion[..0x10.min(region.len())] {
|
||||
continue;
|
||||
}
|
||||
let mut matched = 0usize;
|
||||
for i in 0..count.min(64) {
|
||||
let at = i * TFRAG_HEADER_SIZE;
|
||||
let disc_h = match region.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE) {
|
||||
Some(h) => h,
|
||||
None => break,
|
||||
};
|
||||
match ram.slice(cand + at, TFRAG_HEADER_SIZE) {
|
||||
Some(h) if invariant_equal(disc_h, h) => matched += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if best.is_none_or(|(_, m)| matched > m) {
|
||||
best = Some((base, matched));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((base, matched)) if matched == count.min(64) => Ok(base),
|
||||
Some((base, matched)) => Err(anyhow!(
|
||||
"tfrags block at {base:#x} matches {matched} of {count} headers on the invariant bytes; \
|
||||
refusing a partial match"
|
||||
)),
|
||||
None => Err(anyhow!(
|
||||
"no tfrags block in the RAM image matches this level ({} candidates for the tfrag-0 \
|
||||
bounding sphere)",
|
||||
candidates.len()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a tfrag header must keep between the disc and RAM: everything except
|
||||
/// the relocated `data` pointer (+0x10..0x14) and the occlusion index
|
||||
/// (+0x3a..0x3c), which the loader renumbers per tfrag.
|
||||
fn invariant_equal(disc: &[u8], ram: &[u8]) -> bool {
|
||||
let keep = |j: usize| !(0x10..0x14).contains(&j) && !(0x3a..0x3c).contains(&j);
|
||||
disc.iter()
|
||||
.zip(ram.iter())
|
||||
.enumerate()
|
||||
.filter(|&(j, _)| keep(j))
|
||||
.all(|(_, (a, b))| a == b)
|
||||
}
|
||||
|
||||
fn i32_at(bytes: &[u8], at: usize) -> Option<i32> {
|
||||
bytes
|
||||
.get(at..at + 4)
|
||||
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
bytes
|
||||
.get(at..at + 4)
|
||||
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// Render a report for a terminal.
|
||||
pub fn print_report(r: &Report, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"level{}: tfrags block at {:#x}, table at {:#x}, {} tfrags",
|
||||
r.level, r.block_base, r.table, r.tfrag_count
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" headers matching the disc on invariant bytes: {}/{}",
|
||||
r.headers_matched, r.tfrag_count
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" data pointers relocated as table + disc offset: {}/{}",
|
||||
r.pointers_relocated, r.tfrag_count
|
||||
);
|
||||
match r.bank_ram {
|
||||
Some(at) => writeln!(
|
||||
out,
|
||||
" light bank in RAM at {at:#x}, byte-identical to the disc's"
|
||||
)?,
|
||||
None => writeln!(
|
||||
out,
|
||||
" the disc's light bank is not in RAM by content; the colours below are\\
|
||||
\n compared against the disc's bank with nothing from the game to confirm it"
|
||||
)?,
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\nvertices compared: {} ({} colour bytes)",
|
||||
r.vertices_compared, r.bytes_compared
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" equal: {} ({:.2}%)",
|
||||
r.bytes_equal,
|
||||
100.0 * r.bytes_equal as f64 / r.bytes_compared.max(1) as f64
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" unlit placeholders: {} of {} vertices ({:.2}%)",
|
||||
r.placeholder_equal,
|
||||
r.vertices_compared,
|
||||
100.0 * r.placeholder_equal as f64 / r.vertices_compared.max(1) as f64
|
||||
);
|
||||
for (class, stat) in &r.classes {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {class}: {} vertices, {} fully equal",
|
||||
stat.vertices, stat.equal
|
||||
);
|
||||
}
|
||||
let _ = writeln!(out, "\ndifference (ours - ram) per channel:");
|
||||
for d in -8..=8 {
|
||||
let n = r.histogram.get(&d).copied().unwrap_or(0);
|
||||
if n > 0 {
|
||||
let bar = "#".repeat((n as f64).log10().ceil() as usize);
|
||||
let _ = writeln!(out, " {d:+3}: {n:8} {bar}");
|
||||
}
|
||||
}
|
||||
// The tail in magnitude buckets: a single far value hiding a count of
|
||||
// differing bytes is the number this report most needs to be legible.
|
||||
for (lo, hi) in [(9, 33), (33, 65), (65, 129), (129, 256), (256, i32::MAX)] {
|
||||
let n: usize = r
|
||||
.histogram
|
||||
.iter()
|
||||
.filter(|(&d, _)| !(-lo..=hi).contains(&d))
|
||||
.map(|(_, n)| n)
|
||||
.sum();
|
||||
if n > 0 {
|
||||
let _ = writeln!(out, " |d| in {lo}..{hi}: {n:8}");
|
||||
}
|
||||
}
|
||||
if !r.mismatches.is_empty() {
|
||||
let _ = writeln!(out, "\nfirst mismatches:");
|
||||
for m in r.mismatches.iter().take(16) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" tfrag {} entry {} channel {}: ours {} ram {}",
|
||||
m.tfrag, m.entry, m.channel, m.ours, m.ram
|
||||
);
|
||||
}
|
||||
}
|
||||
for b in &r.breakdowns {
|
||||
let Some(t) = &b.terms else {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\ntfrag {} entry {}: no light set for select {:#06x}; the pass yields transparent\
|
||||
\n black, the game wrote {:?}\n (record base {:?}, disc placeholder {:?})",
|
||||
b.tfrag, b.entry, b.select, b.ram, b.base, b.disc
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\ntfrag {} entry {}: ours {:?} ram {:?} (select {:#06x}, set {:?}, blended {})",
|
||||
b.tfrag, b.entry, t.out, b.ram, t.select, t.set_index, t.blended
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" normal (az {}, el {}) = {:.3} {:.3} {:.3}",
|
||||
t.azimuth, t.elevation, t.normal[0], t.normal[1], t.normal[2]
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" base {:?} A colour {:.3} {:.3} {:.3} (w {:.3}) B colour {:.3} {:.3} {:.3} (w {:.3})",
|
||||
t.base,
|
||||
t.color_a[0], t.color_a[1], t.color_a[2], t.wa,
|
||||
t.color_b[0], t.color_b[1], t.color_b[2], t.wb
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dA {:.3} -> max {:.3} dB {:.3} -> max {:.3}",
|
||||
t.dot_a, t.max_a, t.dot_b, t.max_b
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dir A {:.3} {:.3} {:.3} dir B {:.3} {:.3} {:.3}",
|
||||
t.dir_a[0], t.dir_a[1], t.dir_a[2], t.dir_b[0], t.dir_b[1], t.dir_b[2]
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_invariant_mask_excludes_the_pointer_and_the_occlusion_index() {
|
||||
let disc = vec![0u8; 0x40];
|
||||
let mut ram = disc.clone();
|
||||
// The relocated pointer: any difference is allowed.
|
||||
ram[0x10..0x14].fill(0xff);
|
||||
// The occlusion index, which the loader renumbers.
|
||||
ram[0x3a] = 1;
|
||||
ram[0x3b] = 2;
|
||||
assert!(invariant_equal(&disc, &ram));
|
||||
// But the bounding sphere is load-invariant and must match.
|
||||
let mut bad = ram.clone();
|
||||
bad[1] = 0x99;
|
||||
assert!(!invariant_equal(&disc, &bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ram_image_must_be_exactly_32mib() {
|
||||
let image = vec![0u8; crate::ee::EE_SIZE];
|
||||
assert!(EeRam::new(&image).is_ok());
|
||||
assert!(EeRam::new(&vec![0u8; 1024]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! A minimal zip reader: enough for a PCSX2 savestate, nothing more.
|
||||
//!
|
||||
//! PCSX2 writes savestates with libzip: a sequence of local file headers
|
||||
//! followed by a central directory. Entries are stored, deflated or
|
||||
//! zstd-compressed, so this reader takes the entry's compression method from
|
||||
//! its local header and decodes with it. Only what the format needs is
|
||||
//! implemented, and what is not needed is rejected rather than guessed at:
|
||||
//!
|
||||
//! * **no streamed entries** — libzip writes the compressed size into the
|
||||
//! local header, and a `data descriptor` (flag bit 3) is an error. Making
|
||||
//! that an error rather than a guess matters: misparsed sizes produce a
|
||||
//! plausible but wrong read of the next entry.
|
||||
//! * **CRC-32 is checked.** A savestate that was truncated or partially
|
||||
//! overwritten would otherwise read as a plausible wrong buffer, and this
|
||||
//! harness exists to compare against real bytes.
|
||||
//!
|
||||
//! Zip64 is not needed for a 32 MiB entry, and is not supported.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// One entry in a zip archive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
/// 0 = stored, 8 = deflate, 93 = zstd.
|
||||
pub method: u16,
|
||||
/// Byte range of the compressed payload within the archive.
|
||||
pub data: std::ops::Range<usize>,
|
||||
/// Expected CRC-32 of the uncompressed payload.
|
||||
pub crc32: u32,
|
||||
/// Uncompressed size in bytes.
|
||||
pub uncompressed_size: usize,
|
||||
}
|
||||
|
||||
/// A parsed zip archive over a borrowed byte range.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Zip {
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
pub const LOCAL_HEADER: u32 = 0x0403_4b50;
|
||||
const METHOD_STORED: u16 = 0;
|
||||
const METHOD_DEFLATE: u16 = 8;
|
||||
const METHOD_ZSTD: u16 = 93;
|
||||
|
||||
impl Zip {
|
||||
/// Walk the local file headers from the start of the archive.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Zip> {
|
||||
let mut entries = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while pos + 4 <= bytes.len() {
|
||||
let sig = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap());
|
||||
if sig != LOCAL_HEADER {
|
||||
if pos == 0 {
|
||||
return Err(anyhow!("not a zip archive"));
|
||||
}
|
||||
// The central directory (or an end-of-central-directory
|
||||
// record) ends the local-header run.
|
||||
break;
|
||||
}
|
||||
let h = LocalHeader::parse(bytes, pos)?;
|
||||
if h.flags & 0x08 != 0 {
|
||||
return Err(anyhow!(
|
||||
"entry {:?} uses a data descriptor; this reader needs sizes in the local header",
|
||||
h.name
|
||||
));
|
||||
}
|
||||
let data_start = pos + 30 + h.name_len + h.extra_len;
|
||||
let data_end = data_start.checked_add(h.compressed_size).ok_or_else(|| {
|
||||
anyhow!("entry {:?} compressed size overflows the archive", h.name)
|
||||
})?;
|
||||
if data_end > bytes.len() {
|
||||
return Err(anyhow!(
|
||||
"entry {:?} at +{data_start:#x} runs {} bytes past the end of the archive",
|
||||
h.name,
|
||||
data_end - bytes.len()
|
||||
));
|
||||
}
|
||||
entries.push(Entry {
|
||||
name: h.name,
|
||||
method: h.method,
|
||||
data: data_start..data_end,
|
||||
crc32: h.crc32,
|
||||
uncompressed_size: h.uncompressed_size,
|
||||
});
|
||||
pos = data_end;
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(anyhow!("zip archive holds no entries"));
|
||||
}
|
||||
Ok(Zip { entries })
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[Entry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.name == name)
|
||||
}
|
||||
|
||||
/// Read and decompress one entry.
|
||||
pub fn read(&self, bytes: &[u8], name: &str) -> Result<Vec<u8>> {
|
||||
let e = self
|
||||
.find(name)
|
||||
.ok_or_else(|| anyhow!("zip archive has no entry {name:?}"))?;
|
||||
let raw = &bytes[e.data.clone()];
|
||||
let out = match e.method {
|
||||
METHOD_STORED => raw.to_vec(),
|
||||
METHOD_DEFLATE => {
|
||||
let mut d = flate2::read::DeflateDecoder::new(raw);
|
||||
let mut out = Vec::with_capacity(e.uncompressed_size);
|
||||
d.read_to_end(&mut out).context("deflate entry")?;
|
||||
out
|
||||
}
|
||||
METHOD_ZSTD => {
|
||||
let d =
|
||||
ruzstd::decoding::StreamingDecoder::new(raw).context("zstd entry header")?;
|
||||
let mut out = Vec::with_capacity(e.uncompressed_size);
|
||||
d.take(e.uncompressed_size.max(1) as u64)
|
||||
.read_to_end(&mut out)
|
||||
.context("zstd entry")?;
|
||||
out
|
||||
}
|
||||
other => return Err(anyhow!("entry {name:?} uses unsupported method {other}")),
|
||||
};
|
||||
if out.len() != e.uncompressed_size {
|
||||
return Err(anyhow!(
|
||||
"entry {name:?} decompressed to {} bytes, header says {}",
|
||||
out.len(),
|
||||
e.uncompressed_size
|
||||
));
|
||||
}
|
||||
if crc32(&out) != e.crc32 {
|
||||
return Err(anyhow!("entry {name:?} failed its CRC-32 check"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalHeader {
|
||||
name: String,
|
||||
method: u16,
|
||||
flags: u16,
|
||||
crc32: u32,
|
||||
compressed_size: usize,
|
||||
uncompressed_size: usize,
|
||||
name_len: usize,
|
||||
extra_len: usize,
|
||||
}
|
||||
|
||||
impl LocalHeader {
|
||||
fn parse(bytes: &[u8], pos: usize) -> Result<LocalHeader> {
|
||||
let fixed = bytes
|
||||
.get(pos..pos + 30)
|
||||
.ok_or_else(|| anyhow!("local header at +{pos:#x} is truncated"))?;
|
||||
let flags = le16(fixed, 6);
|
||||
let method = le16(fixed, 8);
|
||||
let crc32 = le32(fixed, 14);
|
||||
let compressed_size = le32(fixed, 18) as usize;
|
||||
let uncompressed_size = le32(fixed, 22) as usize;
|
||||
let name_len = le16(fixed, 26) as usize;
|
||||
let extra_len = le16(fixed, 28) as usize;
|
||||
let name_start = pos + 30;
|
||||
let name_end = name_start + name_len;
|
||||
let name = bytes
|
||||
.get(name_start..name_end)
|
||||
.ok_or_else(|| anyhow!("entry name at +{pos:#x} runs past the archive"))?;
|
||||
let name = String::from_utf8(name.to_vec())
|
||||
.map_err(|_| anyhow!("entry name at +{pos:#x} is not UTF-8"))?;
|
||||
Ok(LocalHeader {
|
||||
name,
|
||||
method,
|
||||
flags,
|
||||
crc32,
|
||||
compressed_size,
|
||||
uncompressed_size,
|
||||
name_len,
|
||||
extra_len,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn le16(b: &[u8], at: usize) -> u16 {
|
||||
u16::from_le_bytes(b[at..at + 2].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE 802.3), the one zip uses. Written out rather than pulled from
|
||||
/// a crate because it is fifteen lines and keeps the lockfile unchanged.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut table = [0u32; 256];
|
||||
for (i, slot) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 {
|
||||
0xEDB8_8320 ^ (c >> 1)
|
||||
} else {
|
||||
c >> 1
|
||||
};
|
||||
}
|
||||
*slot = c;
|
||||
}
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
|
||||
}
|
||||
c ^ 0xFFFF_FFFF
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stored entry, written by hand so the test does not need an encoder.
|
||||
fn stored_zip(name: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
||||
out.extend_from_slice(&METHOD_STORED.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // time
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // date
|
||||
out.extend_from_slice(&crc32(payload).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
// A minimal end-of-central-directory record; the reader stops at it.
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 18]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_stored_entry_and_checks_its_crc() {
|
||||
let z = stored_zip("eeMemory.bin", b"0123456789");
|
||||
let zip = Zip::parse(&z).expect("should parse");
|
||||
assert_eq!(zip.entries().len(), 1);
|
||||
assert_eq!(zip.entries()[0].name, "eeMemory.bin");
|
||||
assert_eq!(zip.read(&z, "eeMemory.bin").unwrap(), b"0123456789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupted_entry_is_an_error_not_a_wrong_read() {
|
||||
let mut z = stored_zip("v.bin", b"0123456789");
|
||||
let last = z.len() - 19 - 5;
|
||||
z[last] ^= 0xff;
|
||||
let zip = Zip::parse(&z).expect("header parsing survives");
|
||||
let err = zip.read(&z, "v.bin").unwrap_err().to_string();
|
||||
assert!(err.contains("CRC-32"), "got {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_descriptor_entry_is_rejected() {
|
||||
let mut z = stored_zip("v.bin", b"payload");
|
||||
// Rewrite the flags field (offset 6) to set bit 3.
|
||||
z[6] = 0x08;
|
||||
let err = Zip::parse(&z).unwrap_err().to_string();
|
||||
assert!(err.contains("data descriptor"), "got {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_entry_is_rejected() {
|
||||
let mut z = stored_zip("v.bin", b"0123456789");
|
||||
// The header declares ten payload bytes; leave one of them missing.
|
||||
// Truncating the end instead would only chew into the central
|
||||
// directory, which this reader stops at before it gets there.
|
||||
let declared = 30 + "v.bin".len() + 10;
|
||||
z.truncate(declared - 1);
|
||||
let err = Zip::parse(&z).unwrap_err().to_string();
|
||||
assert!(err.contains("past the end"), "got {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_the_known_value() {
|
||||
// The standard check value for CRC-32/ISO-HDLC.
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
}
|
||||
|
||||
/// A deflate entry. The stream is the deflate encoding of `b"AB"` produced
|
||||
/// by an independent encoder (Python's zlib, raw wbits), so this checks
|
||||
/// against something this code did not make.
|
||||
#[test]
|
||||
fn reads_a_deflate_entry() {
|
||||
let deflated: &[u8] = &[115, 116, 2, 0];
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&METHOD_DEFLATE.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crc32(b"AB").to_le_bytes());
|
||||
out.extend_from_slice(&(deflated.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(b"v.bin");
|
||||
out.extend_from_slice(deflated);
|
||||
let zip = Zip::parse(&out).expect("should parse");
|
||||
assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB");
|
||||
}
|
||||
|
||||
/// A zstd entry, the way PCSX2 writes them. The frame is the zstd encoding
|
||||
/// of `b"AB"` from an independent encoder (Python 3.14's zipfile), and it
|
||||
/// uses raw blocks the way a seven-byte payload needs.
|
||||
#[test]
|
||||
fn reads_a_zstd_entry() {
|
||||
let zstd_frame: &[u8] = &[40, 181, 47, 253, 0, 88, 17, 0, 0, 65, 66];
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&93u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crc32(b"AB").to_le_bytes());
|
||||
out.extend_from_slice(&(zstd_frame.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(b"v.bin");
|
||||
out.extend_from_slice(zstd_frame);
|
||||
let zip = Zip::parse(&out).expect("should parse");
|
||||
assert_eq!(zip.entries()[0].method, 93);
|
||||
assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user