Compare commits

...
6 Commits
Author SHA1 Message Date
ReaperOfVeriod aa2b14ad66 The shrub mesh geometry is in the class blob after all
The open question from the shrub decode was where the mesh lives: the blob
looked like header, palette and optional billboard, with the 333 classes
without a billboard having nothing to draw. Answering it took the running
game, not the disc.

Diffing a loaded shrub class against its disc bytes in the live PCSX2 (level
1, class 1) shows what the loader rewrites: a table at +0x40 of (offset,
size) pairs, whose offsets are span-relative on disc and absolute in RAM with
the sizes unchanged -- all 21 entries matching offset for offset and size for
size -- plus a relocated palette pointer at +0x2c and a pointer at +0x18,
zero on disc, that asserts the parser enforces.

So the blob is header, packet table, packets, [billboard], palette: the mesh
was here the whole time. Why it was missed is the part worth recording --
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 correct bound and a body small enough to
look plausible are together enough to hide a region.

Range measured: every shrub span on the disc, 358 over 24 levels, each bounded
by its successor class or the located chunk container.

Method: read count at +0x28, then the entries at +0x40, and require them to
tile -- the first packet at align16(0x40 + count*8), every entry exactly size
past the previous, and the last ending at the palette, or 0x40 short of it on
the 25 spans that carry a billboard.

Result: 358 of 358 tile exactly, holding 2,239 packets. That is what pins the
field meanings: count is the packet count (1..55 on the disc), data_offset is
the palette's offset, and the earlier "the body is not sized by count" note
was true for a reason nobody had understood -- the count sizes the packets.

Shrub::packets() exposes them and returns None rather than a partial list
when a table does not tile. Still open: the packet contents (a VIF reading was
already retracted once, so this needs a control rather than a pattern match),
and the 0x20-byte records the loader points at from +0x18.
2026-10-12 02:02:01 +02:00
ReaperOfVeriod c83f5104d2 Document the PCSX2 test setup
PCSX2 was set up and working but existed only in this machine's filesystem
and in people's heads, so every comparison looked like a one-off observation
rather than something re-runnable. The setup section in
docs/plan/pcsx2-verification.md now records where the emulator, the BIOS, the
disc, the savestates and the PINE socket are, how a state is captured, and
which command the disc integration tests need -- and AGENTS.md and README.md
point at it from their orientation sections. One operational detail worth
spelling out: the emulator's directory is lowercase (pcsx2), which is not
what people type.
2026-10-11 20:59:19 +02:00
ReaperOfVeriod bbdb51e2f9 Verify level 1's tfrag lighting against the game
Savestate slot 2 is the second planet, so the comparison runs on a level with
a different geometry extent, light bank and runtime-set population:

  level1: 401/401 headers, 401/401 pointers, bank byte-identical
  vertices compared: 26470 (105880 colour bytes)  -- equal: 100.00%

The live emulator agrees with the savestate byte for byte. Level 1 is a
different planet, so the two fixes in the previous commit are not level-0
coincidences: the normal's z sign and the unlit-record base colour both hold
across 268,280 colour bytes on two levels.
2026-10-11 20:50:36 +02:00
ReaperOfVeriod 69a8398f6e Verify the tfrag lighting magnitudes against the running game
The harness that compares the port against the game's own memory existed but
had never been compiled: tools/trace did not build (27 errors, all in the
argument plumbing), and the tfrag_light.rs light-bank correction it motivated
was unverified. This finishes it and runs it, and the run finds two real bugs.

Result, on level 0 against three inputs -- the live emulator over PINE and two
PCSX2 savestates from the same level, agreeing byte for byte:

  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%)

The locator finds the level's tfrags block in RAM by tfrag 0's bounding
sphere and accepts a candidate only when every header matches the disc on the
bytes the game leaves alone and every data pointer is the relocated form, so
"the block was found" is itself a verification rather than an assumption. The
bank is searched in RAM by its exact section bytes, which is what settles its
framing: offset at +0x04, an i32 set count, twelve bytes of pad, then the sets.
The previous reading (offset at +0x00, count at +0x04) was wrong.

Two bugs the comparison found, both of which produced plausible output:

1. vertex_normal_bits negated x and y and left z positive, while
   NormalTable::normal negates all three. dA came out -0.707 where the game
   had +0.707, so max(dA, dA*wA) took the wrong branch and every vertex lit
   near its base colour: dark, but not wrong-dark. 95.88% agreement with the
   bug, 100.00% without.
2. A record whose select names a set the bank lacks drew transparent black.
   The game writes the record's base colour; 3,130 of level 0's 40,600
   vertices do this (select 0x000f, set 15 -- a runtime slot the level's
   directional bank does not have). The vertex is unlit, not invisible.

Both are now regression tests in the engine. The comparison prints the pass's
per-vertex terms (light_vertex_terms) for the first mismatches, because a
colour mismatch alone cannot be attributed and the comparison is worthless
without attribution -- that is how the second bug was identified from a
three-line read rather than guessed at.

Docs: docs/plan/pcsx2-verification.md records the harness, the result, the
two bugs and what to compare next; roadmap and level-assets.md stop carrying
lighting magnitudes as an open item. Findings reach tests only as numbers --
no test reads a savestate, a dump or a live emulator.
2026-10-11 19:51:25 +02:00
ReaperOfVeriod 2c7555fe16 Decode the shrub class blob: a 24-normal palette and a billboard record
The shrub body's payload encoding was the open question after the span-bound
work, and it answered itself once the R&C1 reference was in view: R&C1's shrub
class header keeps the field offsets R&C2 shows (o_class at 0x24, a count at
0x28, the data offset at 0x2c), which 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.

Result. The body is a palette of 24 unit-length s16 normals -- s16 x, y, z and
a zero pad word per 8-byte record -- followed by all-zero padding records.
Three independent measurements, each of which fails for any other reading:

- 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
  is 32768^2 - 32767^2, which is what a unit vector scaled by 32768 produces
  when an axis-aligned normal quantises to -32768 and the positive axis clamps
  to 32767; components run the full s16 range and 1,765 entries are
  axis-aligned. A random s16 triple is off by about a billion, so one entry in
  fifteen thousand would pass by chance: all 8,592 pass.
- the padding records are always at index 24 or later and always all zero,
  never a live entry. That is what separates the palette from geometry, and it
  is the observation the three earlier stride-fitting attempts were missing.
- 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.

The header decodes as: sphere at +0x00 (centre + radius, radius positive on
all 358 spans, 1.72..41.57), size at +0x10, mode_bits and scratch at +0x14,
a zero word at +0x18, the billboard offset at +0x1c, and scale at +0x20. The
parser asserts the two invariants that hold without exception (scratch and the
reserved word are zero on all 358) plus a positive radius and scale, and
reports mode_bits rather than asserting it: it is 4 on 16 of the 358 spans.

The billboard offset is the strongest self-identification 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, pairing
correctly both ways. At the offset sits R&C1's ShrubBillboard shape, register
block and all -- fade_distance, then TEX1_1 (K -233..-168, MMIN 4, address
0x14), TEX0_1 (index 1..4, address 0x06) and MIPTBP1_1 (address 0x34), all
three register addresses checked in the disc test.

Two corrections this produces, both recorded in docs/plan/level-assets.md
because the old readings were confidently wrong rather than merely
unverified:

- "reserved is 0 or a denormal" had the two u16 halves the wrong way round:
  the 16 non-zero spans are mode_bits = 4 with scratch = 0. As an f32 the word
  is still a denormal, so the PS2 reads zero either way -- but the field is a
  flag word, not a magnitude, which is exactly the FPU point worth making.
- "bounds" was tested as a min.xyz/max.xyz box, which holds on only 146 of
  358 spans, and the field was left unnamed as a result. It is a bounding
  sphere: the fourth float is positive on all 358 spans and the centres
  cluster near the class origin. The 146/358 figure was a property of the
  wrong question.

Still open: the three floats in the billboard record (they repeat
byte-identically across levels -- 16 distinct triples among 25 records, one on
four classes across levels 1, 4, 12 and 21 -- so they are shared art data
rather than dimensions), size, mode_bits, count, and where the shrub mesh
geometry lives. R&C1 puts the mesh packets in this blob; R&C2's blob holds
only the palette, so the 333 classes without a billboard must draw meshes from
somewhere else.
2026-10-11 13:33:37 +02:00
ReaperOfVeriod ccea53807a Resolve the shrub region bound: the chunk container closes it
The shrub region's end was the last unlocated bound in core_data. It was not
in the core index, and unlike moby->tie it had no successor boundary to chain
against, so the last shrub class in every level ran out to the lump's end and
the aggregates over it (the "3.5M records / 28MB of shrub body") were
artifacts of that oversized span.

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 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. The two halves are each weak alone, and the halves are measured
separately: the marker pair 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 to need four tagged offsets before it is believed.

Result: on all 27 levels the marker sits exactly two bytes before the first
chunk header, and the two parts of the signature are never found apart. No
chunk signature occurs inside any last shrub body, on any level. The three
levels without shrub (10, 25, 26) carry the same marker before the container
that closes tie instead, which is what makes the locator a property of the
region rather than a shrub rule.

Consequences:

- All 358 shrub class bodies are bounded and are whole 8-byte records. That
  was not assertable while the last class in each level ran to the lump's end,
  and it is the check the boundary earns its keep for.
- 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. The
  shrub region totals 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). The container measurements were previously taken over "the
  900,000 bytes before the trailer", a window that did not align to its real
  extent: on level 0 it is 690,302 bytes, and 27 of 1,123 chunks were
  truncated at that invented edge. chunk_containers_resolve_on_every_level now
  reads the region from core_layout instead of measuring backwards from the
  end of the lump.

core_layout takes core_data now: the container's start is the one bound that
cannot be derived from the index. It gains the chunks region, so every byte of
level-specific core_data belongs to a named region. Where the signature is not
found the end stays None -- the region is left visible rather than clamped to
the tail.

Still open: the marker's meaning (constant across 27 levels, at exactly this
position, unnamed) and the shrub body payload's encoding.
2026-10-11 06:20:48 +02:00
23 changed files with 3730 additions and 263 deletions
+1
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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"
+6
View File
@@ -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
+124 -14
View File
@@ -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));
}
}
+1 -1
View File
@@ -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 [
+400 -89
View File
@@ -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 {
assert!(
s.body_is_well_formed(),
"level{n} class {i}: bounded 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();
}
// 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}: shrub body is not whole 8-byte records"
);
bounded_bodies += 1;
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 {
+105
View File
@@ -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);
}
}
+151 -22
View File
@@ -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
View File
@@ -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,
}
/// One mesh packet: where it sits in the span and how long it is.
///
/// 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 body's 8-byte records.
///
/// 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 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]
+45 -6
View File
@@ -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.
}
}
+69 -14
View File
@@ -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
View File
@@ -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.
+165
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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 }
+121
View File
@@ -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));
}
}
+35
View File
@@ -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};
+344
View File
@@ -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}"))
}
+182
View File
@@ -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());
}
}
+189
View File
@@ -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);
}
}
+597
View File
@@ -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 = &region[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(&region[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 = &region[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 != &region[..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());
}
}
+336
View File
@@ -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");
}
}