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.
337 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|