diff --git a/AGENTS.md b/AGENTS.md index 8c51272..98e0b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,13 +77,20 @@ Read these before working in an unfamiliar area: | `gc-extract` | Disc pipeline: identify, unpack, verify. | | `gc-game` | Game logic: hero, weapons, classes, progression, saves. | | `gc-video` | IPU/MPEG-2 decode. | -| `gc-engine` | Bevy app: GS, VU0/VU1, renderer, frame loop. | +| `gc-engine` | Bevy app: GS, VU0/VU1, renderer, frame loop. Also owns the **hardware models** — `ps2.rs` is the Emotion Engine's FPU, and the tfrag lighting pass runs on it. | The dependency direction is `gc-formats` ← `gc-extract`; `gc-formats` ← `gc-game` ← `gc-engine`; `gc-video` ← `gc-game` ← `gc-engine`. `gc-formats` depends on nothing of ours. Do not invert these — a format parser that can reach into the engine cannot be tested in isolation. +A hardware model belongs in `gc-engine`, not `gc-formats`. `gc-formats` +decodes *inputs* — including the data a hardware model consumes, such as the +overlay's sine table — but arithmetic that reproduces how the PS2 computes +something is an engine concern. `tfrag_light.rs` (formats) holds the normal +table and the light bank framing; `tfrag_light.rs` (engine) holds the pass +that uses them. + ## Conventions - Rust 2021 edition, workspace inheritance for all shared config. Do not diff --git a/crates/gc-engine/src/level.rs b/crates/gc-engine/src/level.rs index abb0b18..917e77b 100644 --- a/crates/gc-engine/src/level.rs +++ b/crates/gc-engine/src/level.rs @@ -211,52 +211,51 @@ pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable [n[0], n[2], -n[1]] } -/// A vertex's colour after the lighting pass. +/// A vertex's colour after the lighting pass, and whether it was lit at all. /// -/// Falls back to the tfrag's stored colour when there is no light bank or no -/// normal table. That is a real difference from the game, which has both, so -/// the renderer reports how many patches were actually lit rather than -/// quietly showing an unlit level as if it were a lit one. +/// Lighting runs over *positions*, not vertex-info entries: a common entry and +/// a LOD entry can share a position, and the light record is indexed by +/// position, so every entry resolves through the same lookup. +/// +/// Takes no distance. The directional pass does not depend on it; the point +/// lights, which are not implemented yet, will. +/// +/// Falls back to the tfrag's stored colour when there is no usable light bank. +/// That is a real difference from the game, so the caller reports how many +/// vertices were actually lit rather than quietly showing stored colours as +/// though they were lit. fn lit_color( g: &TfragGeometry, vinfo: usize, stored: [f32; 4], lighting: Option<(&NormalTable, &LightBank)>, - depth: f32, - lod_distances: [f32; 3], ) -> ([f32; 4], bool) { - let _ = (depth, lod_distances); - // Lighting runs over *positions*, not vertex-info entries: a common entry - // and a LOD entry can share a position, and the light record is indexed by - // position. So every entry resolves through the same lookup. - let Some((table, bank)) = lighting else { + let Some((table, bank)) = lighting.filter(|(_, b)| !b.sets.is_empty()) else { return (stored, false); }; - if bank.sets.is_empty() { - return (stored, false); - } - (lit_at(g, vinfo, table, bank), true) -} - -fn lit_at(g: &TfragGeometry, vinfo: usize, table: &NormalTable, bank: &LightBank) -> [f32; 4] { let i = g.position_index(vinfo); let Some(light) = g.lights.get(i) else { - return [1.0, 1.0, 1.0, 1.0]; + return ([1.0, 1.0, 1.0, 1.0], true); }; let n = crate::tfrag_light::vertex_normal_bits( table, light.azimuth_index(), light.elevation_index(), ); - let Some(c) = crate::tfrag_light::light_vertex(bank, n, light) else { - return [1.0, 1.0, 1.0, 1.0]; - }; - [ - c[0] as f32 / 255.0, - c[1] as f32 / 255.0, - c[2] as f32 / 255.0, - 1.0, - ] + match crate::tfrag_light::light_vertex(bank, n, light) { + Some(c) => ( + [ + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + 1.0, + ], + true, + ), + // The light set this vertex names does not exist. Count it as unlit + // rather than inventing a colour. + None => (stored, false), + } } /// The stored colour of a vertex's position, or opaque white if absent. @@ -441,7 +440,7 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result -``` +The failure mode is the one worth keeping: a uniform failure on all 27 inputs +was taken to mean the decoder was wrong, when it meant the *data* was being +handed over uncompressed. A positive control -- decompress it and look at the +result -- was never run, and it would have answered the question in one line. -The variation is the tell. For the LZ77 WAD container the fourth byte is part of -a 32-bit little-endian length; here byte 3 differs per level (0x79, 0x3a, 0xd8, -0xa9, ...) while byte 4 is a small count (13, 88, 13, 105, 163, ...) and bytes 6 -and 7 are always zero. - -**Attempt: treat it as the known WAD LZ77 stream.** Decoded all 27 levels with -the decoder in `wad.rs`, starting after an 8-byte header. Every one produces -plausible output for the first packet — `0000 0000 0000 0750 0100 0020 2b0c...` -looks like real level data — and then fails on all 27 with "adjacent literal -packets", which the format's encoder never emits. - -A uniform failure on all 27 inputs means the input is wrong, not the decoder. -Ruled out: - -- **Bit order.** The WAD container is a flag-byte packet stream, not a bit - stream, so no ordering applies. Both orderings fail identically. -- **Header length.** Tried 8 bytes. A different skip would not fix a failure - that occurs uniformly in the first few packets. -- **The WAD format at all.** The first packet (flag `0x00`) decodes cleanly as a - literal run of 18 bytes, which is *why* it looks plausible, but the run - immediately after breaks the format's own rule. Whatever this is, it is not - WAD LZ77. - -Next to try, in order of cheapness: - -1. Whether the payload is uncompressed and the header is a table. `(len - 8)` - is 196..497 across the levels and divides by neither 8 nor a plausible stride, - which argues against a flat pair table — but a *variable-length* table - (counts of counts) fits that range. -2. Whether byte 4 is an entry count and bytes 5.. are records of a fixed but - non-obvious width. -3. Whether the LZ77 variant differs only in the "adjacent literals" rule. - -None of these is expensive to test; each is a few lines against all 27 levels at -once. The right move is to write a throwaway script that tries many hypotheses -across all 27 and reports which produce self-consistent results — the approach -that worked for the container pair table, where "does this hold on every level" -was the discriminator that the sector/byte ambiguity failed. +See "The light bank was there the whole time" below. ## `sound_bank`: signatures found, structure unknown @@ -170,11 +138,20 @@ Earlier claims in this file that have since been **disproven**: Tested: every stride from 2 to 16 bytes, every starting offset, three and four components. Best result 5.0% of points on-sphere, which is chance. -So the record framing is **not recovered**. What is established is the region -layout and that terrain is vertex-soup packed alongside inline texels — which -means the vertex format must be identified from a longer run than a single -record, most likely by locating a run of vertices whose magnitudes are bounded -by the planet radius and whose Y values vary smoothly. +So the record framing is **not recovered** — but that framing was a false lead, +and the terrain has since been parsed in full. This section records the wrong +turn and is kept only so it is not repeated. + +The region layout below is right and terrain really is packed alongside inline +texels, but the "64-byte record" idea was the wrong way in. Tfrags are not a +flat vertex table: they are VIF1 command lists whose unpacks are classified by +format *and* destination VU address, and the vertices come out of a V3_16 +unpack spread across three lists per patch. See "tfrag terrain" below for the +framing that works and the four wrong turns along the way. + +The lesson is the recurring one in this file: a hypothesis that produces +plausible-looking output is not thereby recovered, and a "ruled out" entry is +only as good as the control it was tested against. ## Where the class tables are not @@ -625,10 +602,22 @@ the fidelity rule. ### Still open -The lighting above, the GS register values the level load builds from the -texture entry (TBP0/TBP1 and so on), and the guard-band clip test that sends -patches crossing the guard band to the clipping program. +- **Point lights.** The directional pass runs; `point_light_vertex` and its + `r^2 - |p - L|^2` cull test are not written, and they are the one part of + the lighting that *is* distance-dependent. +- **The GS register set** the level load builds from the texture entry + (TBP0/TBP1 and so on), for a renderer that emulates the GS rather than + 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. +- **The `core_index` class tables**, still unread after every field order and + stride tried. tfrag, texture and lighting data came from identifying each + structure on its own terms instead. -A tfrag can now be turned into positioned, UV-mapped, textured triangles, +A tfrag can now be turned into positioned, UV-mapped, textured, lit triangles, told which LOD to draw, and morphed toward its parents at the right rate. -Everything needed for a first frame is parsed and verified. +Everything needed for a first frame is parsed, and the frame is verified by +screenshot rather than by assertion. diff --git a/docs/roadmap.md b/docs/roadmap.md index 053a1f1..05a764f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,8 +146,10 @@ Phase 1 hands over the extents; this phase works out what each one contains. and decompressed sizes of `core_data` sit at the same offsets in all 27 levels), and every offset lands inside `core_data`. But the class-table fields do not decode under any field order or stride tried. Ruled out and - written up in `plan/level-assets.md`. Next: identify `tfrag` by its own - structure instead of through the header. + written up in `plan/level-assets.md`. Identifying `tfrag` by its own + structure instead, which is what the note suggested, worked: tfrag, + texture and lighting data are all parsed. The class tables themselves + remain unread. - [x] `occlusion` and `sky` offsets located and their first bytes read. - [x] Region layout of `core_data` established: tfrag, occlusion, sky, collision and `textures_base` are offsets, strictly increasing on all 27 levels.