From a02f79a6d16dba0419cb43ecf68df9479361d000 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Fri, 9 Oct 2026 15:42:10 +0200 Subject: [PATCH] Initialise native Rust port of Ratchet & Clank: Going Commando Sets up the foundation for reimplementing the PS2 game in Rust rather than emulating it. Nothing is implemented yet; this is the scaffold, the verified target and the working agreements that keep a long project coherent. Target disc, verified against a real image: SCUS_972.68 v1.01, USA Black Label, NTSC 60 Hz boot ELF 2,618,684 bytes, SHA-1 bacda860...d098a77, entry 0x00131AE8 The entry point independently matches the value recorded by the rac2-gc-decomp community reference, which cross-confirms the identification. This is the exact build the upstream research pins; Greatest Hits v2.00 is a different binary and is not the target. NTSC matters beyond label compatibility: PAL discs run 50 Hz, which would make chargeboots and other speed-sensitive movement run at 5/6 speed. Verifying the disc before writing movement code avoids discovering that in Phase 5. Contents: - Five-crate workspace (formats, extract, game, video, engine) on Bevy 0.19.1, matching the version ReRAC uses so its Graphics Synthesizer and VU work stays portable. `cargo check --workspace` is clean. - tools/identify-disc.py, which reproduces the identification against any disc image and distinguishes a truncated dump from a non-PS2 image. - .githooks/pre-commit, refusing game data from entering git history: disc image extensions, asset directories, container signatures at offset 0, and files over 50 MiB. Verified against seven cases including a disc image renamed to look like source. Now that this pushes to a remote, this matters more than it would have locally. - AGENTS.md, the operating agreements: no game data ever, reproduce the PS2 float model rather than approximating it, bound every read, verify against the real disc. - PROVENANCE.md, recording that this follows ReRAC (ISC) and learns from rac2-gc-decomp (MIT). Wrench is used as a written specification only and no GPLv3 code is copied, since that would relicense everything derived from it. RAC2Decomp is explicitly not used: its provenance is disputed in the upstream community reference, and it targets the wrong region. - docs/roadmap.md, phased so the riskiest unknowns are retired early. The two that matter: overlay relocation transfers from ReRAC and makes 27 level overlays tractable, while VU1 microcode is binary and is the hardest single item. Phase 5 is a deliberately scoped Veldin vertical slice as the go/no-go. --- .githooks/pre-commit | 100 + .gitignore | 38 + AGENTS.md | 129 + Cargo.lock | 5563 ++++++++++++++++++++++++++++++++++ Cargo.toml | 82 + PROVENANCE.md | 120 + README.md | 106 + crates/gc-engine/Cargo.toml | 15 + crates/gc-engine/src/lib.rs | 35 + crates/gc-extract/Cargo.toml | 14 + crates/gc-extract/src/lib.rs | 29 + crates/gc-formats/Cargo.toml | 16 + crates/gc-formats/src/lib.rs | 38 + crates/gc-game/Cargo.toml | 14 + crates/gc-game/src/lib.rs | 38 + crates/gc-video/Cargo.toml | 12 + crates/gc-video/src/lib.rs | 19 + docs/disc-identification.md | 87 + docs/roadmap.md | 194 ++ tools/identify-disc.py | 231 ++ 20 files changed, 6880 insertions(+) create mode 100755 .githooks/pre-commit create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PROVENANCE.md create mode 100644 README.md create mode 100644 crates/gc-engine/Cargo.toml create mode 100644 crates/gc-engine/src/lib.rs create mode 100644 crates/gc-extract/Cargo.toml create mode 100644 crates/gc-extract/src/lib.rs create mode 100644 crates/gc-formats/Cargo.toml create mode 100644 crates/gc-formats/src/lib.rs create mode 100644 crates/gc-game/Cargo.toml create mode 100644 crates/gc-game/src/lib.rs create mode 100644 crates/gc-video/Cargo.toml create mode 100644 crates/gc-video/src/lib.rs create mode 100644 docs/disc-identification.md create mode 100644 docs/roadmap.md create mode 100755 tools/identify-disc.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e6a41c0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Pre-commit guard: refuse to commit game data, disc images, or anything +# unusually large into this repository. +# +# This repository is a from-scratch Rust reimplementation. It contains NO +# game assets, NO disc images, NO BIOS dumps and NO proprietary SDK binaries. +# The build takes your own legally obtained copy of the disc as input. +# +# Hooks live in .githooks/ and are activated by `git config core.hooksPath`. +# To bypass in an emergency: git commit --no-verify + +set -uo pipefail + +MAX_BYTES=$((50 * 1024 * 1024)) # 50 MiB + +RED=$'\033[31m'; YEL=$'\033[33m'; RST=$'\033[0m' +errors=0 + +# Collect staged files (new/modified/renamed copies), NUL-separated so we +# survive spaces and odd characters in paths. +mapfile -d '' staged < <( + git diff --cached --name-only --diff-filter=ACMR -z 2>/dev/null +) + +if [ ${#staged[@]} -eq 0 ]; then + exit 0 +fi + +for path in "${staged[@]}"; do + # Blobs that were removed from the worktree still exist in the object DB. + if ! [ -f "$path" ]; then + size=$(git cat-file -s ":$path" 2>/dev/null || echo 0) + else + size=$(stat -c%s "$path" 2>/dev/null || echo 0) + fi + + # 1) Disc-image / asset extensions. + case "$path" in + *.iso|*.bin|*.img|*.cue|*.mdf|*.rom|*.rom1|*.rom2) + printf '%sBLOCKED%s %s\n' "$RED" "$RST" "$path" + printf ' looks like a disc image, BIOS dump or raw asset blob.\n' + errors=1 + continue + ;; + esac + + # 2) Known extracted-asset directories. + case "/$path/" in + */extracted/*|*/assets/*|*/iso/*|*/disc/*) + printf '%sBLOCKED%s %s\n' "$RED" "$RST" "$path" + printf ' lives in an asset directory that must stay untracked.\n' + errors=1 + continue + ;; + esac + + # 3) Oversized files (catches archives and anything else we did not think of). + if [ "$size" -gt "$MAX_BYTES" ]; then + printf '%sBLOCKED%s %s (%s MiB, limit 50)\n' \ + "$RED" "$RST" "$path" "$(( size / 1024 / 1024 ))" + errors=1 + continue + fi + + # 4) Content signature: ISO 9660 / WAD / CRI ADX container magic at offset 0. + # Catches disc images renamed to look like source. Compare the first five + # bytes as hex so binary bytes (NUL, 0x10, ...) cannot perturb the match. + # 2048 bytes is one PS2 logical sector: the smallest plausible asset. + if [ -f "$path" ] && [ "$size" -ge 2048 ]; then + sig=$(head -c 5 "$path" 2>/dev/null | xxd -p | tr -d '\n') + case "$sig" in + 4344303031*|574144*|414458*) + printf '%sBLOCKED%s %s\n' "$RED" "$RST" "$path" + printf ' begins with a disc/asset container signature (%s).\n' "${sig:0:10}" + errors=1 + continue + ;; + esac + fi +done + +if [ "$errors" -ne 0 ]; then + cat < +``` + +Reports the serial, version and video mode from `SYSTEM.CNF`, hashes the boot +executable, and warns when the disc is not the supported build. Reads only a +few megabytes, so it is near-instant. + +## How it will be verified + +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). + +## Credits and licensing + +This project learns from and is built on the work of others. Licences are +recorded in [`PROVENANCE.md`](PROVENANCE.md) and notices must travel with any +code derived from them. + +- **ReRAC** — a native Rust/Bevy port of *Ratchet & Clank* (2002), the first + game. ISC. The architecture this project follows. +- **OpenRAC / rac2-gc-decomp** — matching decompilation research for this + game. MIT. Source of verified struct layouts and engine documentation. +- **Wrench** — community documentation of Insomniac's container formats. GPL, + used as a specification reference only; no code is copied. + +Ratchet & Clank is a trademark of Sony Interactive Entertainment. This is an +unofficial fan project, not affiliated with or endorsed by Sony Interactive +Entertainment or Insomniac Games. \ No newline at end of file diff --git a/crates/gc-engine/Cargo.toml b/crates/gc-engine/Cargo.toml new file mode 100644 index 0000000..c5fb1c2 --- /dev/null +++ b/crates/gc-engine/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gc-engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Bevy application: Graphics Synthesizer, VU0/VU1 and the renderer" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +gc-formats.workspace = true +gc-game.workspace = true +bevy.workspace = true diff --git a/crates/gc-engine/src/lib.rs b/crates/gc-engine/src/lib.rs new file mode 100644 index 0000000..0d5a413 --- /dev/null +++ b/crates/gc-engine/src/lib.rs @@ -0,0 +1,35 @@ +//! The application: renderer, hardware models, and the frame loop. +//! +//! Reimplements the pieces of the PlayStation 2 that the game talks to, so the +//! game logic in `gc-game` can be written as ordinary Rust that draws and +//! sounds like the original. +//! +//! # Hardware being modelled +//! +//! * **Graphics Synthesizer (GS)** - fixed-function rasteriser. No shaders on +//! the real hardware: the GS does texture mapping, alpha and blend rules, +//! and z/stencil. Here that becomes wgpu plus explicit handling of the GS's +//! own rules (its `round(log2(z/n) + K)` mip selection, its blend and alpha +//! test behaviour, its alpha-0x80-is-1.0 convention). +//! * **VU0 / VU1** - vector units running microcode uploaded from the disc. +//! Skinning, lighting and transform setup live here. The microcode is +//! **binary** in the game's `.vutext` section, so it is reverse engineered +//! rather than parsed: this is the single hardest item in the project. +//! * **DMA and GIF/VIF packets** - the game streams geometry to the GS through +//! tag-split packet lists. Those packet formats are the input to rendering. +//! * **SPU2 / IOP** - audio and the disc interface, in `gc-video` and +//! `gc-extract` respectively. +//! +//! # Divergence from the first game +//! +//! R&C2's levels are spherical worlds, so camera, culling, fog and the sky are +//! all effectively rewritten compared with Ratchet & Clank (2002). Geometry +//! subsystems carry over in shape - `tfrag`, `tie`, `shrub`, `moby` are the +//! same four - but their behaviour on a sphere is new work. +//! +//! # Reference frame +//! +//! The port is validated against PCSX2 by replaying recorded input and +//! comparing frames. Without that loop, "looks close" and "is correct" drift +//! apart, and this game's difficulty and movement make that drift very +//! noticeable. \ No newline at end of file diff --git a/crates/gc-extract/Cargo.toml b/crates/gc-extract/Cargo.toml new file mode 100644 index 0000000..d773957 --- /dev/null +++ b/crates/gc-extract/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gc-extract" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Disc pipeline: identify, unpack and verify game data from a Going Commando disc image" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +gc-formats.workspace = true +sha1.workspace = true diff --git a/crates/gc-extract/src/lib.rs b/crates/gc-extract/src/lib.rs new file mode 100644 index 0000000..573dedb --- /dev/null +++ b/crates/gc-extract/src/lib.rs @@ -0,0 +1,29 @@ +//! Disc pipeline for *Ratchet & Clank: Going Commando* (PS2). +//! +//! Turns a legally obtained disc image into verified, unpacked asset files that +//! the engine can read directly, and does nothing else. +//! +//! # Stages +//! +//! 1. **identify** - read `SYSTEM.CNF`, confirm serial/version/video mode and +//! hash the boot ELF. Reports clearly when a disc is not the supported +//! build. A Python reference implementation of this stage lives in +//! `tools/identify-disc.py`. +//! 2. **unpack** - read `RC2.HDR`, walk the table of contents, decompress WAD +//! streams and write per-level and global asset directories. +//! 3. **verify** - check every extracted file against a recorded size and +//! SHA-1 table, so a corrupted or mismatched dump cannot quietly produce a +//! subtly wrong game. +//! +//! # Output layout +//! +//! Extracted data is written outside the repository (see `.gitignore`). The +//! pre-commit hook in `.githooks/` refuses disc images, asset directories and +//! oversized files, so nothing here can end up in git history by accident. +//! +//! # Verification is not optional +//! +//! Every parser downstream is only as trustworthy as the bytes it was handed. +//! A hash table committed to the repo (sizes and hashes only, never content) +//! is what lets a test say "this parse matches the retail disc" rather than +//! "this parse matches whatever happened to be on my disk". \ No newline at end of file diff --git a/crates/gc-formats/Cargo.toml b/crates/gc-formats/Cargo.toml new file mode 100644 index 0000000..22e280c --- /dev/null +++ b/crates/gc-formats/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gc-formats" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Parsers for Ratchet & Clank: Going Commando (PS2) asset formats" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +bytemuck.workspace = true +sha1.workspace = true + +[dev-dependencies] \ No newline at end of file diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs new file mode 100644 index 0000000..14bfa22 --- /dev/null +++ b/crates/gc-formats/src/lib.rs @@ -0,0 +1,38 @@ +//! Asset format parsers for *Ratchet & Clank: Going Commando* (PS2). +//! +//! Target: `SCUS_972.68` v1.01, USA Black Label, NTSC 60 Hz. See +//! `docs/disc-identification.md`. +//! +//! This crate reads data structures out of the game and produces plain Rust +//! values. It performs no rendering, no simulation and no I/O beyond what the +//! caller hands it, which keeps every parser testable against a byte slice. +//! +//! # What belongs here +//! +//! * `iso9660` - the disc filesystem. R&C2 lists its assets as real files, +//! unlike R&C1, so enumeration works. +//! * `hdr` - `RC2.HDR`, the table of contents (408,444 bytes at the +//! disc root). The engine addresses assets by sector, and +//! this is where those sector numbers come from. +//! * `wad` - Insomniac's LZ77 container (`WAD` magic, 16-byte header). +//! Carries level core data, audio and video streams. +//! * `tfrag` - terrain fragments: LOD chains, strips, morph targets. +//! * `tie` - instanced environment geometry ("ties"), with fat vertices. +//! * `shrub` - instanced foliage and decorative meshes. +//! * `moby` - dynamic actors, including Ratchet, enemies and crates. +//! * `moby_anim` - skeletal animation: sequences, frames, blending. +//! * `collision` - the collision world: cells, packed vertices, faces. +//! * `occlusion` - the PVS grid used for visibility. +//! * `sky`, `volumes`, `textures` - sky shells, fog volumes, CSM1 textures. +//! +//! # Rules +//! +//! **Bound every read.** Malformed data must produce a typed error, never a +//! panic and never a read past the end of the buffer. +//! +//! **No game data in this repository.** Parsers are written against a copy of +//! your own legally obtained disc, and the extracted output stays outside git. +//! +//! **Verify against the real thing.** Every parser gets a test that loads +//! known-good bytes from your disc and checks the parsed result. See +//! `docs/formats/` for the specs being implemented. \ No newline at end of file diff --git a/crates/gc-game/Cargo.toml b/crates/gc-game/Cargo.toml new file mode 100644 index 0000000..dc41ca9 --- /dev/null +++ b/crates/gc-game/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gc-game" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Game logic: hero controller, weapons, moby classes, progression and saves" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +gc-formats.workspace = true +gc-video.workspace = true diff --git a/crates/gc-game/src/lib.rs b/crates/gc-game/src/lib.rs new file mode 100644 index 0000000..89343a9 --- /dev/null +++ b/crates/gc-game/src/lib.rs @@ -0,0 +1,38 @@ +//! Game logic for *Ratchet & Clank: Going Commando* (PS2). +//! +//! The rules, systems and behaviour of the game, reimplemented from the +//! original code rather than wrapped around it. No emulation, no interpreter: +//! this is a Rust implementation that is meant to behave the way the game does. +//! +//! # What belongs here +//! +//! * `hero` - Ratchet's controller. Movement on a **spherical** planet, +//! which is what distinguishes this game: reaching for the +//! camera and the collision code changes gravity handling +//! across the whole world, and is the main reason the first +//! game's movement code does not transfer. +//! * `chargeboots`- hold and release timing, chained jumps and the dash. +//! * `weapons` - the wrench and the weapon set, plus the weapon modification +//! system (per-weapon bitmasks: lock-on, shock, acid, ...). +//! * `gadgets` - the Pyrocitor, Bomb Glove, Glove of Doom and friends. +//! * `moby_update`- the per-class update dispatch, driven by each level's +//! `lvl.vtbl` class table. Classes that are identical across +//! levels are shared; the rest are resolved through the +//! overlay relocator in `gc-formats`. +//! * `collision` - queries against the collision world. +//! * `progression`- bolts, the Gadgetron vendor, RPG upgrades and unlocks. +//! * `save_game` - the IFF chunk save format, and importing a Ratchet & Clank +//! (2002) save to unlock the legacy weapons for free. +//! * `memcard` - the 25-state memory-card finite state machine and its UI. +//! +//! # The float model +//! +//! The Emotion Engine's FPU is not IEEE-754 in every respect: it has no +//! denormals, and rounding differs in places. Gameplay code here reproduces +//! that model rather than using Rust's native `f32`, because the difference is +//! visible in the movement and the camera. See `docs/plan/float-model.md`. +//! +//! # Timing +//! +//! The game is 60 Hz (this disc is NTSC). Constants are expressed per tick at +//! that rate. PAL discs run 50 Hz and would need different timing throughout. \ No newline at end of file diff --git a/crates/gc-video/Cargo.toml b/crates/gc-video/Cargo.toml new file mode 100644 index 0000000..eeacade --- /dev/null +++ b/crates/gc-video/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "gc-video" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "IPU/MPEG-2 video stream decoding for Ratchet & Clank: Going Commando" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true diff --git a/crates/gc-video/src/lib.rs b/crates/gc-video/src/lib.rs new file mode 100644 index 0000000..33bb22e --- /dev/null +++ b/crates/gc-video/src/lib.rs @@ -0,0 +1,19 @@ +//! Video decoding for *Ratchet & Clank: Going Commando* (PS2). +//! +//! R&C2 uses the Emotion Engine's **IPU** to stream MPEG-2 video, most visibly +//! on the title screen where Ratchet watches television. This crate decodes +//! those streams to raw frames for the host GPU to upload. +//! +//! # Note on scale +//! +//! The MPEG WADs on this disc total roughly 2.4 GB (about 1.0 GB, 1.3 GB and +//! 115 MB). Video is a data problem before it is a decoding problem, so the +//! pipeline needs to stream rather than hold a whole movie in memory. +//! +//! # Pipeline +//! +//! `bits.rs` -> `vlc.rs` -> `idct.rs` -> `mpeg2.rs` +//! +//! MPEG-2 decoding is a solved, well-specified problem, and a general-purpose +//! decoder will produce correct output. The PS2-specific part is only the +//! container: how the streams are stored and addressed on the disc. \ No newline at end of file diff --git a/docs/disc-identification.md b/docs/disc-identification.md new file mode 100644 index 0000000..b9d3094 --- /dev/null +++ b/docs/disc-identification.md @@ -0,0 +1,87 @@ +# Disc identification — VERIFIED 2026-10-09 + +Your disc was identified while initialising this repository. It is the **best +case target**: the exact build that `OpenRAC/rac2-gc-decomp` pins. + +## Result + +| Field | Value | +|---|---| +| Title | Ratchet & Clank: Going Commando | +| Serial | `SCUS_972.68` | +| Version | **1.01** (Black Label) | +| Video mode | **NTSC** (60 Hz) | +| Region | USA | +| ISO 9660 volume | `RATCHETANDCLANK2` | +| Image size | 3,828,350,976 bytes | +| Publisher / Preparer | SONY / INSOMNIAC | + +### Boot executable (`SCUS_972.68`) + +| Property | Value | +|---|---| +| Size | 2,618,684 bytes | +| SHA-1 | `bacda860cbd6723142e86db56f197e47e728970f` | +| CRC-32 | `262705496` | +| Format | ELF32, little-endian, MIPS (EM_MIPS = 8) | +| Entry point | `0x00131AE8` | + +The entry point `0x00131AE8` matches the value independently recorded by the +`rac2-gc-decomp` community reference for this target, which cross-confirms the +identification. + +## Why this matters + +**`VER = 1.01` + `VMODE = NTSC` is exactly what we want.** + +- **v1.01 is the pinned target.** `rac2-gc-decomp` states plainly that + *Greatest Hits v2.00 is a different target — do not point a build at it.* + You have the Black Label original. +- **NTSC means 60 Hz.** This is the detail that decides the movement constants. + PAL discs report `VMODE = PAL` and run the game at 50 Hz, which makes the + chargeboots and other speed-sensitive movement run at 5/6 of their intended + speed. Building against NTSC timing constants is correct for this disc. + +There is no ambiguity to resolve, so no disc-conversion work is required. + +## Not used for identification + +Redump's catalogue records the **first track** hash for this disc as +`f2cfaf0eb12b3c0d8fd6db7aa49ce032e8a6020b` (CRC-32 `3828350976`). The values +computed above are the **boot executable's** hash, which is a different thing: +a single file rather than a track. Both are stable identifiers, but they must +not be compared against each other. We identify by serial + `VER` + `VMODE` + +boot-ELF SHA-1, which is unambiguous. + +## Disc layout (observed) + +R&C2 lists its assets in the ISO 9660 filesystem, unlike R&C1. Confirmed +entries at the disc root: + +``` +SYSTEM.CNF 60 bytes +RC2.HDR 408,444 <- the table of contents, as a named file +SCUS_972.68 2,618,684 <- the boot ELF +IOPRP255.IMG 264,449 +Z6TAIL.DUP 0 +G/... <- global WADs (LEVEL*, AUDIO*, SCENE*, MPEG*, HUD, …) +``` + +`RC2.HDR` being present as a real file is convenient: the extractor can read +the table of contents directly instead of hunting for it at a fixed sector as +with R&C1. The `G/` WADs are listed in the filesystem, so enumeration works +too. + +### Observation on MPEG WADs + +The three MPEG WADs are large — roughly 1.0 GB, 1.3 GB and 115 MB. That is a +substantial fraction of the 3.8 GB disc and will matter for the video phase +(R&C2 uses IPU/FMV streams, notably the title screen). Worth factoring into the +plan: video is a large *data* problem before it is a decoding problem. + +## Reproducing this check + +`tools/identify-disc.py` performs the same identification against any +R&C2 disc image and prints the table above. It reads only the ISO 9660 +structures, `SYSTEM.CNF` and the boot ELF (a few MB of seeks), so it runs in +well under a second on a local image. \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..06b324a --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,194 @@ +# Roadmap + +Status as of **2026-10-09**. + +A from-scratch native Rust reimplementation of Ratchet & Clank: Going Commando +(PS2). Each phase ends in a **gate** — something checkable, not a feeling that +it is going well. Phases are ordered so that the riskiest unknowns are retired +early, while they are still cheap to retire. + +## Where we are + +- [x] Target disc identified and verified (`SCUS_972.68` v1.01, NTSC 60 Hz) +- [x] Workspace builds (`cargo check --workspace`) +- [x] Repository hygiene in place (pre-commit guard, `.gitignore`, licence + record) +- [ ] Nothing else implemented yet + +The disc being the exact build the reference projects pin is the single +biggest favourable fact so far: no disc conversion, no regional timing work, +and no re-derivation of the reference baseline. + +--- + +## Phase 0 — Foundation ✅ + +**Done.** + +- Disc identified: `SCUS_972.68` v1.01, USA Black Label, NTSC 60 Hz. Boot ELF + SHA-1 `bacda860cbd6723142e86db56f197e47e728970f`, entry `0x00131AE8` — the + entry point independently matches the value recorded by `rac2-gc-decomp`, + which cross-confirms the identification. +- `tools/identify-disc.py` reproduces the identification against any disc image + and distinguishes a truncated dump from a non-PS2 image. +- Five-crate workspace scaffolding, Bevy 0.19.1 pinned, builds clean. +- `.githooks/pre-commit` blocks game data. Verified against seven cases: + `.iso`, renamed disc image, WAD container, ADX container, `extracted/`, + a 60 MiB file, and legitimate source. +- `AGENTS.md` and `PROVENANCE.md` record conventions and licences. + +### Open from this phase + +- `alsa-lib-devel` is not installed, so Bevy's host audio backend is disabled in + `Cargo.toml`. Audio is the last phase; revisit then. +- Bevy feature set is deliberately minimal. Re-add features as needed rather + than switching `default-features` back on wholesale. + +--- + +## Phase 1 — Disc pipeline + +**Goal:** turn the disc image into verified, unpacked asset files. + +- [ ] `iso9660` reader (R&C2 lists assets as real files, unlike R&C1) +- [ ] `RC2.HDR` table of contents parser (408,444 bytes at disc root) +- [ ] WAD LZ77 decompression +- [ ] Unpack global WADs (`G/*.WAD`) and per-level WADs +- [ ] Commit a size+SHA-1 table per target file (hashes only, never content) +- [ ] `verify` stage + +**Gate:** every extracted file matches the committed hash table, and the +decompressed size of every WAD stream matches its header. + +**Note:** the three MPEG WADs total roughly 2.4 GB of the 3.8 GB disc. Video is +a data problem before it is a decoding problem — plan to stream. + +--- + +## Phase 2 — Format parsers + +**Goal:** every asset type parses into plain Rust values, tested against real +disc data. + +- [ ] `tfrag` — terrain: LOD chains, strips, morph targets +- [ ] `tie` — instanced environment geometry, fat vertices +- [ ] `shrub` — foliage and decorative meshes +- [ ] `moby` — dynamic actors; `moby_anim` — skeletal animation +- [ ] `collision` — cells, packed vertices, faces +- [ ] `occlusion` — the PVS grid +- [ ] `sky`, `volumes`, `textures` (CSM1 CLUT + PS2 alpha) +- [ ] `level_overlay` — the seven-section parser and relocation from Phase 3 + +**Gate:** every parser has a golden test against bytes from the real disc, and +reports mismatched header counts as failures rather than warnings. + +**Discipline:** bound every read. Typed errors, no panics, no reads past the +buffer. + +--- + +## Phase 3 — Overlay system + +**Goal:** make 27 level overlays tractable. + +Every overlay carries the same seven sections (`.lit`, `.bss`, `.data`, +`lvl.vtbl`, `lvl.camvtbl`, `lvl.sndvtbl`, `.text`) and the same engine code, +linked at different addresses. ReRAC solves this by masking relocations — `j`/ +`jal` targets, `lui` immediates of main-RAM addresses, the `%lo` that completes +them, and `$gp`-relative operands — then matching the masked words to find a +reference function's copy on another overlay. + +- [ ] Port the masking and code-identity logic +- [ ] `Relocation::func` and `Relocation::data` +- [ ] `$gp` for this game (`0x001AEFF0`, against R&C1's `0x166c00`) +- [ ] Extend the level table from 19 to 27 + +**Gate:** every level-01 function's relocated copy is found on all 27 overlays; +cross-checked against an independent cluster hash. + +**Why this is high-value:** it turns "27 overlays of unknown code" into a table +lookup, and it is the main thing inherited from ReRAC. + +--- + +## Phase 4 — Engine core + +**Goal:** draw a frame. + +- [ ] Graphics Synthesizer in wgpu, with the GS's own rules: its + `round(log2(z/n) + K)` mip selection, blend and alpha-test behaviour, + alpha `0x80` meaning 1.0, CLAMP wrap +- [ ] VU0/VU1 skinning and lighting on the PS2 float model +- [ ] LOD/morph/cull per `TfragProc` and `TieProc` +- [ ] Occlusion culling, fog, shadows +- [ ] GIF/VIF packet stream interpretation + +**Gate:** CPU and GPU paths agree on every vertex of a reference level — the +same identity check ReRAC runs. + +**Risk:** VU1 microcode is **binary** in the game's `.vutext` section, so it is +reverse engineered rather than parsed. This is the hardest single item in the +project and the main threat to the timeline. Note that ReRAC's own moby VU1 +analysis trips their disassembler on two opcodes. + +--- + +## Phase 5 — Veldin vertical slice + +**Goal:** one planet, playable start to finish. + +- [ ] Ratchet's controller on a **spherical** planet +- [ ] Chargeboots: hold/release timing, chained jumps, dash +- [ ] Wrench and the camera +- [ ] Collision and the HUD + +**Gate:** Veldin is completable and visually matches PCSX2 frame-for-frame under +replayed input. + +**This is the go/no-go for the project.** If Veldin works, the other 26 levels +are content. If it does not, the reason is discovered while it is still cheap. +Spherical gravity is the thing most likely to break, because it invalidates +R&C1's movement and camera assumptions wholesale. + +--- + +## Phase 6 — Remaining overlays and the shell + +- [ ] Per-level moby class dispatch via `lvl.vtbl` +- [ ] Remaining 26 levels +- [ ] Planets, travel, the Gadgetron vendor, RPG upgrades +- [ ] Weapon modification bitmasks (lock-on, shock, acid, …) +- [ ] Minigames and challenges +- [ ] The 25-state memory-card FSM and its UI +- [ ] IFF chunk saves, including import of a R&C1 save to unlock legacy weapons + +--- + +## Phase 7 — Audio and video + +- [ ] 989snd v2 audio decode and playback (not CRI ADX — a different codec) +- [ ] SPU2 mixing, reverb zones, level sounds +- [ ] IPU/MPEG-2 video for the title screen and cutscenes + +--- + +## Phase 8 — Fidelity pass + +Differential testing against PCSX2 across every level: + +- [ ] Recorded-input frame comparison, per planet +- [ ] Geometry, fog, blend, alpha, texture sampling +- [ ] Audio sync and 60 Hz timing +- [ ] Behavioural comparison: traversal routes, boss timing, RNG + +--- + +## Principles + +- **A gate is pass/fail, not a percentage.** "Looks fine" is not progress. +- **Negative results are written down.** A ruled-out approach saves the next + attempt as much as a working one. +- **The float model is not negotiable.** Approximate is not the same as + faithful, and the difference is visible in how the game feels. +- **Fidelity over tidiness.** Where the original does something that looks like + a bug, reproduce it and say why in a comment. See `AGENTS.md`. \ No newline at end of file diff --git a/tools/identify-disc.py b/tools/identify-disc.py new file mode 100755 index 0000000..fb62c47 --- /dev/null +++ b/tools/identify-disc.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Identify a Ratchet & Clank: Going Commando disc image. + +Reads only the ISO 9660 structures, SYSTEM.CNF and the boot ELF, so it needs a +few megabytes of seeks rather than a full read. Reports the serial, version and +video mode, and warns when the disc is not the build our references target. + + python3 tools/identify-disc.py + python3 tools/identify-disc.py --verbose + +Only your own legally obtained disc is read. Nothing is written anywhere. +""" + +from __future__ import annotations + +import argparse +import hashlib +import struct +import sys +import zlib +from dataclasses import dataclass, field +from pathlib import Path + +SECTOR = 2048 + +# The build OpenRAC/rac2-gc-decomp pins, and the one our reference material +# describes. Boot-ELF SHA-1 below is measured from a retail v1.01 image. +TARGET_SERIAL = "SCUS_972.68" +TARGET_VERSION = "1.01" +TARGET_ELF_SHA1 = "bacda860cbd6723142e86db56f197e47e728970f" + +# Serial -> (game, edition) for the R&C discs we can recognise. +KNOWN_TITLES = { + "SCUS_972.68": ("Going Commando (USA)", "Black Label v1.01"), + "SCUS_972.68GH": ("Going Commando (USA)", "Greatest Hits v2.00 - DIFFERENT TARGET"), + "SCES_516.07": ("Going Commando (PAL)", "Locked and Loaded v1.00 - 50 Hz"), + "SCPS_150.56": ("Going Commando (JP)", "Gagaga! Ginga no Commando-ssu"), + "SCKA_200.11": ("Going Commando (KR)", ""), + "SCUS_971.99": ("Ratchet & Clank (USA)", "the first game, not Going Commando"), + "SCES_509.16": ("Ratchet & Clank (PAL)", "the first game, not Going Commando"), +} + + +class NotAnIso(Exception): + pass + + +@dataclass +class Iso: + """Minimal read-only ISO 9660 view: enough to find the root directory.""" + + path: Path + volume_id: str = "" + root_lba: int = 0 + root_size: int = 0 + files: dict[str, tuple[int, int]] = field(default_factory=dict) + + def read(self, lba: int, size: int) -> bytes: + with open(self.path, "rb") as fh: + fh.seek(lba * SECTOR) + data = fh.read(size) + if len(data) < size: + raise NotAnIso( + f"image is truncated: wanted {size} bytes at LBA {lba}, got {len(data)}" + ) + return data + + +def open_iso(path: Path) -> Iso: + size = path.stat().st_size + with open(path, "rb") as fh: + magic = fh.read(SECTOR * 16 + 6) + + # The CD001 signature sits at offset 0x8001 in the Primary Volume Descriptor. + if magic[0x8001:0x8006] != b"CD001": + # A 2352-byte raw dump has a different layout; we only support .iso. + head = magic[:5] + raise NotAnIso( + "not an ISO 9660 image (no CD001 signature at 0x8001); " + "only plain 2048-byte-sector .iso dumps are supported" + ) + + iso = Iso(path=path) + with open(path, "rb") as fh: + fh.seek(16 * SECTOR) + pvd = fh.read(SECTOR) + iso.volume_id = pvd[40:72].decode("latin1").strip() + + root = pvd[156:190] + iso.root_lba = struct.unpack_from(" str: + for line in cnf.splitlines(): + if "=" not in line: + continue + k, v = line.split("=", 1) + if k.strip() == key: + return v.split(";")[0].strip() + return "" + + +def elf_summary(blob: bytes) -> str: + if len(blob) < 52 or blob[:4] != b"\x7fELF": + return "not an ELF" + bits = {1: "ELF32", 2: "ELF64"}.get(blob[4], "?") + endian = {1: "little", 2: "big"}.get(blob[5], "?") + machine = struct.unpack_from(" int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("iso", type=Path, help="path to your own disc image (.iso)") + ap.add_argument("--verbose", "-v", action="store_true", help="list root directory") + args = ap.parse_args() + + if not args.iso.is_file(): + print(f"error: {args.iso} is not a file", file=sys.stderr) + return 2 + + try: + iso = open_iso(args.iso) + except NotAnIso as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print("Disc identification") + print("=" * 62) + print(f" file {args.iso.name}") + print(f" size {args.iso.stat().st_size:,} bytes") + print(f" volume id {iso.volume_id or '(none)'}") + + if "SYSTEM.CNF" not in iso.files: + # A truncated dump still has a valid PVD, so distinguish "damaged" + # from "genuinely not a PS2 disc". + if iso.volume_id: + print( + f"\n SYSTEM.CNF is missing but the image declares volume id " + f"{iso.volume_id!r}, which means the dump is truncated or damaged.", + file=sys.stderr, + ) + return 4 + print("\n no SYSTEM.CNF: not a PlayStation 2 game disc", file=sys.stderr) + return 3 + + lba, size = iso.files["SYSTEM.CNF"] + cnf = iso.read(lba, size).decode("latin1") + + boot = cnf_value(cnf, "BOOT2").replace("cdrom0:\\", "").replace("\\", "/") + serial = boot.rsplit("/", 1)[-1] + version = cnf_value(cnf, "VER") + vmode = cnf_value(cnf, "VMODE") + + print(f" BOOT2 {boot}") + print(f" serial {serial}") + print(f" version {version or '(absent)'}") + print(f" video mode {vmode or '(absent)'}") + + if serial in KNOWN_TITLES: + title, edition = KNOWN_TITLES[serial] + print(f" title {title}") + if edition: + print(f" edition {edition}") + + if boot in iso.files: + blba, bsize = iso.files[boot] + elf = iso.read(blba, bsize) + sha1 = hashlib.sha1(elf).hexdigest() + print() + print("Boot executable") + print("-" * 62) + print(f" size {bsize:,} bytes") + print(f" SHA-1 {sha1}") + print(f" CRC-32 {zlib.crc32(elf)}") + print(f" format {elf_summary(elf)}") + else: + print("\n the boot executable named by SYSTEM.CNF is not in the root directory") + sha1 = None + + if args.verbose: + print() + print("Root directory") + print("-" * 62) + for name in sorted(iso.files): + l, s = iso.files[name] + print(f" {name:24s} LBA {l:>8,} {s:>12,} bytes") + + print() + print("=" * 62) + is_target = serial == TARGET_SERIAL and version == TARGET_VERSION + if is_target and sha1 == TARGET_ELF_SHA1: + print(" This is the build we target: USA Black Label v1.01, NTSC 60 Hz.") + print(" Expected SHA-1 of the boot executable matches.") + elif serial == TARGET_SERIAL: + print(f" Serial matches, but version is {version or 'unknown'}" + f" and we target {TARGET_VERSION}.") + print(" Greatest Hits v2.00 is a different build and will not match") + print(" the reference material. Everything else should still work.") + else: + print(f" Not the target build. We target {TARGET_SERIAL} v{TARGET_VERSION}.") + if vmode == "PAL": + print(" PAL disc: the game runs at 50 Hz, so movement runs at 5/6") + print(" speed. Plan timing constants accordingly.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file