Files
Going-Commando/tools/trace/src/zip.rs
T
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

337 lines
13 KiB
Rust

//! 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");
}
}