diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index cba45f7..c5ac062 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -5051,6 +5051,100 @@ fn occ(hay: &[u8], needle: &[u8]) -> Vec { .collect() } +/// Scratch: which gameplay section holds a run of point lights? +#[test] +fn scratch_point_lights() { + let Some(data) = extracted_root() else { + eprintln!("skipped: unpack the disc first"); + return; + }; + require_disc!(); + + // A point light is {r,g,b,intensity} then {x,y,z,radius}, 0x20 bytes: the + // colour and intensity small, the radius positive. + let plausible = |b: &[u8]| -> bool { + let w = |k: usize| f32::from_le_bytes(b[k * 4..k * 4 + 4].try_into().unwrap()); + let c = [w(0), w(1), w(2)]; + let intensity = w(3); + let p = [w(4), w(5), w(6)]; + let radius = w(7); + c.iter() + .chain([&intensity]) + .all(|v| v.is_finite() && v.abs() < 4.0) + && p.iter().all(|v| v.is_finite() && v.abs() < 1e4) + && radius.is_finite() + && radius > 0.0 + && radius < 1e4 + }; + + for lv in 0..3usize { + let Ok(gp) = std::fs::read(data.join(format!("level{lv}/gameplay.bin"))) else { + continue; + }; + let mut hits: Vec = Vec::new(); + for at in (0..gp.len().saturating_sub(0x20)).step_by(4) { + if plausible(&gp[at..at + 0x20]) { + hits.push(at); + } + } + eprintln!( + "level{lv}: {} plausible 0x20 records in {} bytes", + hits.len(), + gp.len() + ); + + // Cluster into runs of consecutive 0x20 records. + let mut run: Vec = Vec::new(); + let mut emit = |run: &mut Vec| { + if run.len() >= 4 { + eprintln!( + " run of {} at {:#x}..{:#x}", + run.len(), + run[0], + run.last().unwrap() + 0x20 + ); + } + run.clear(); + }; + for &at in &hits { + if run.last().is_some_and(|&l| at == l + 0x20) { + run.push(at); + } else { + emit(&mut run); + run.push(at); + } + } + emit(&mut run); + + // And show the first record of any run of exactly eight. + let mut run: Vec = Vec::new(); + for &at in &hits { + if run.last().is_some_and(|&l| at == l + 0x20) { + run.push(at); + } else { + if run.len() == 8 { + let b = &gp[at - 0x100..at]; + for r in 0..8 { + let w = |k: usize| { + f32::from_le_bytes( + b[r * 0x20 + k * 4..r * 0x20 + k * 4 + 4] + .try_into() + .unwrap(), + ) + }; + eprintln!( + " light {r}: colour ({:.3},{:.3},{:.3}) intensity {:.3} pos ({:.1},{:.1},{:.1}) radius {:.1}", + w(0), w(1), w(2), w(3), w(4), w(5), w(6), w(7) + ); + } + } + run.clear(); + run.push(at); + } + } + } +} + /// Every level texture decodes to a GS TEX0 register. /// /// The GS addresses textures by exponent, so TW and TH are `log2` of the diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index f344e81..78a264e 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -2646,3 +2646,53 @@ That is the same mistake one level down from the section-indexing bug: I used a decoding I had never checked to produce results, and then measured the results with the same unverified decoding. The check existed as a tool from the first commit. + +## Camera position in EE RAM: the differential measurement + +The guard-band clip test needs the camera, and the doc says so explicitly -- "both +are per-frame game state, so this needs the camera at a savestate". Two savestates +give a differential: **anything that moved between them is per-frame state**, and +anything static is not. That is the measurement, and it needs no knowledge of where +the camera lives. + +Frames `.01` and `.04`, both 33,554,432 bytes. Of every 16-byte-aligned triple of +finite floats with magnitude 0.001..100,000, **2,005 differ** between the two. + +Two shapes in the output, and they are clearly different things: + + 0x152c70 [247.000, 194.000, 49.890] -> [300.170, 227.480, 128.000] + + 0x188660 [251.864, 195.641, 51.895] -> [298.396, 224.011, 128.948] + 0x188670 [251.864, 195.641, 51.895] -> [298.396, 224.011, 128.948] + 0x188680 [251.864, 195.641, 51.895] -> [298.396, 224.011, 128.948] + 0x188690 [251.864, 195.641, 51.895] -> [298.396, 224.011, 128.948] + +The `0x1886xx` block repeats the same triple four times at 16-byte stride -- that +is a per-object array, not a camera. And `0x18aa70` holds +`[0.033, -0.212, 0.977] -> [0.506, 0.644, 0.574]`, a near-unit vector that reads as +a direction rather than a position. + +**`0x152c70` is the interesting one** and it is only a candidate: + + [247.000, 194.000, 49.890] -> [300.170, 227.480, 128.000] + +Small positive coordinates in the range Aranos occupies, moving between frames, at +an address that is a singleton rather than an array element. + +**Not claimed as the camera position.** 2,005 candidates is not a shortlist, and +this stretch has produced enough confident-looking numbers built on unverified +premises that the bar for saying "this is X" is an independent check, not a +plausible shape. + +**What would confirm it**, in order of cheapness: + +1. The same address should move *smoothly* between adjacent frames. Take `.01`, + `.02`, `.03`, `.04` and check the series is monotone-ish rather than jumping -- + a camera between consecutive frames moves a little, not 50 units. +2. It should be a **singleton**: the camera is one thing, not an array. `0x152c70` + is already in a sparse region, but that needs confirming rather than assuming. +3. Perturb the camera in an emulator and watch that address alone change. + +Point 3 is the decisive one and it is cheap -- it is the same technique the +lighting verification used. Points 1 and 2 need no emulator at all and run off the +savestates already on disk.