From 07e93ce0411b06a17bbee014efc4007fc49c4236 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 19:40:23 +0000 Subject: [PATCH] re: a nested record's +0x08 is its loop length -- the plate's period is 120, not 105 Answers the question the port agent asked: does the `PRESS A` plate's pulse group loop from its start, or hold at alpha 0 between cycles? It holds. A nested record is itself a RATC bundle with its own header, and that header's `+0x08` is the loop length -- the same field ui_header_time_disc already tests as an animation length at the top level. Its keyframes need not fill it, and the slack is a hold at the final pose. `ptbtn00f` is 105 units of ramp inside a 120-unit cycle, so the glow rests dark for 15 units between pulses. The five main-menu focus records fill their 120 exactly, which is what shows the slack belongs to this record rather than to the format. Disc-wide over 1 781 timed nested records: 92.3% declare exactly their last keyframe time, 7.7% declare more, and 0 declare less. That last row is the falsifier -- a cycle cannot restart before its own last pose -- and it never fires; the 7.7% is what keeps the reading from being an unfalsifiable relabelling of the keyframes. Falsification against the running game, using a pacing factor measured INDEPENDENTLY on the main menu's focus ring (declared 120 units, measured 2.177 s, factor 1.0885): to reach the corpus's four measurements of the plate pulse (2.12/2.19/2.34/2.31 s), a 105-unit period needs a factor of 1.211-1.337, which EXCLUDES the ring's; a 120-unit period needs 1.060-1.170, which CONTAINS it. Predicted 2.177 s against a measured 2.12-2.34. The two elements are in different bundles and were measured in separate runs; the only thing tying them together is that both declare 120. So the port should stop shipping 105. Its 123-vs-129 ambiguity straddled the right answer without containing it, and 129 only fitted because it was 105 + the exit_ramp_units constant it has since correctly deleted. Reach is stated: this says where a cycle ends, not which records cycle, and the TOP-level +0x08 is a different field left untouched -- every GP_TITLE entry declares 300 while its elements end at 244-269. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- crates/sylpheed-formats/examples/_all2.rs | 38 ++++++ crates/sylpheed-formats/examples/_rechdr.rs | 27 ++++ .../examples/record_loop_length.rs | 69 ++++++++++ .../tests/ui_record_loop_length_disc.rs | 121 ++++++++++++++++++ docs/port/HANDOFF.md | 57 +++++++++ docs/re/INDEX.md | 1 + docs/re/data/record-loop-length-census.txt | 20 +++ docs/re/structures/ui-record-loop-length.md | 106 +++++++++++++++ 8 files changed, 439 insertions(+) create mode 100644 crates/sylpheed-formats/examples/_all2.rs create mode 100644 crates/sylpheed-formats/examples/_rechdr.rs create mode 100644 crates/sylpheed-formats/examples/record_loop_length.rs create mode 100644 crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs create mode 100644 docs/re/data/record-loop-length-census.txt create mode 100644 docs/re/structures/ui-record-loop-length.md diff --git a/crates/sylpheed-formats/examples/_all2.rs b/crates/sylpheed-formats/examples/_all2.rs new file mode 100644 index 00000000..f559a97b --- /dev/null +++ b/crates/sylpheed-formats/examples/_all2.rs @@ -0,0 +1,38 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap(); + let ar=pak::PakArchive::open(pk).unwrap(); + let by=ar.read(&ar.entries()[i]).unwrap(); + let b=ui_layout::parse_build(&by).unwrap(); + println!("entry {i}: {} elements, {} records, {} sprites", + b.elements.len(), b.records.len(), b.sprites.len()); + let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort(); + println!(" records: {:?}", rk); + for (rn,&(o,sz)) in &b.records { + if o+sz>by.len() { continue } + let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue }; + println!(" RECORD {rn}: {} elements", lb.elements.len()); + for le in &lb.elements { + let lts:Vec=le.keyframes.iter() + .map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect(); + println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" ")); + } + } + for e in &b.elements { + let ts:Vec=e.keyframes.iter() + .map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect(); + println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" ")); + if let Some(&(o,s))=b.records.get(&e.name) { + if o+s<=by.len() { + if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) { + for le in &lb.elements { + let lts:Vec=le.keyframes.iter() + .map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect(); + println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" ")); + } + } + } + } + } +} diff --git a/crates/sylpheed-formats/examples/_rechdr.rs b/crates/sylpheed-formats/examples/_rechdr.rs new file mode 100644 index 00000000..56493e7c --- /dev/null +++ b/crates/sylpheed-formats/examples/_rechdr.rs @@ -0,0 +1,27 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); + let ar=pak::PakArchive::open(pk).unwrap(); + for t in a { + let i:usize=t.parse().unwrap(); + let by=ar.read(&ar.entries()[i]).unwrap(); + let Some(b)=ui_layout::parse_build(&by) else { continue }; + let top=u32::from_be_bytes(by[8..12].try_into().unwrap()); + println!("entry {i:2} TOP +08 = {top}"); + let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort(); + for rn in ks { + let &(o,s)=b.records.get(rn).unwrap(); + if o+16>by.len() { continue } + let magic=&by[o..o+4]; + let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap()); + let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap()); + let maxt=ui_layout::parse_build(&by[o..o+s]) + .map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter() + .filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0); + println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}", + String::from_utf8_lossy(magic), h4, h4 as f64/65536.0, + if maxt>0 {h8 as f64/maxt as f64} else {0.0}); + } + } +} diff --git a/crates/sylpheed-formats/examples/record_loop_length.rs b/crates/sylpheed-formats/examples/record_loop_length.rs new file mode 100644 index 00000000..88a70934 --- /dev/null +++ b/crates/sylpheed-formats/examples/record_loop_length.rs @@ -0,0 +1,69 @@ +//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes +//! have to fill it? +//! +//! A `*f` focus record animates forever while its button is focused, so +//! something must say where the cycle restarts. The keyframes cannot: the plate's +//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 % +//! short of every measurement of the real thing. +//! +//! Each record is itself a RATC bundle with its own header, and `+0x08` is a +//! frame count. If it is the loop length then it must never be LESS than the +//! record's largest keyframe time — an animation cannot restart before its own +//! last pose — and it may be more, which is a hold at the final pose. +//! +//! Two controls, both of which a wrong reading fails: +//! * `+0x08 < max keyframe time` must never happen. That is the falsifier. +//! * The distribution must not be trivial: if every record had exactly +//! `+0x08 == max t`, the field would carry nothing and "loop length" would be +//! an unfalsifiable relabelling of the keyframes. +use sylpheed_formats::{pak, ratc, ui_layout}; +use std::collections::BTreeMap; + +fn main() { + let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into()); + let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/") + .flatten().map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect(); + paks.sort(); + let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize); + let mut hold_hist: BTreeMap = BTreeMap::new(); + let mut worst: Vec<(i64, String)> = Vec::new(); + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { continue } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for (rn, &(o, s)) in &b.records { + if o + 12 > by.len() || o + s > by.len() { continue } + if &by[o..o + 4] != b"RATC" { continue } + let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64; + let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue }; + let maxt = lb.elements.iter() + .flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) + .max().unwrap_or(0) as i64; + if maxt == 0 { continue } // a static record declares no cycle + total += 1; + let slack = len - maxt; + *hold_hist.entry(slack).or_default() += 1; + if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else { + violations += 1; + if worst.len() < 12 { + worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}", + p.file_name().unwrap().to_string_lossy()))); + } + } + } + } + } + println!("nested records with timed keyframes : {total}"); + println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64); + println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64); + println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier", + 100.0*violations as f64/total as f64); + println!("\nslack (+08 - max t) distribution, most common first:"); + let mut h: Vec<_> = hold_hist.iter().collect(); + h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n)); + for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); } + if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } } +} diff --git a/crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs b/crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs new file mode 100644 index 00000000..eaeac8d1 --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs @@ -0,0 +1,121 @@ +//! A nested record's header `+0x08` is its LOOP LENGTH, and its keyframes need +//! not fill it. +//! +//! A `*f` focus record animates for as long as its button is focused, so +//! something has to say where the cycle restarts. The keyframes cannot: the +//! `PRESS Ⓐ` plate's `ptbtn00f` ramps 0→80→0 over **105** units, and a 105-unit +//! period is 15 % short of every measurement of the real thing. +//! +//! Each record is itself a RATC bundle with its own header. `+0x08` is a frame +//! count, and if it is the loop length it must never be **less** than the +//! record's largest keyframe time — an animation cannot restart before its own +//! last pose. Disc-wide that holds 1 781 times out of 1 781, and 7.7 % of records +//! declare *more*, which is a hold at the final pose before the cycle repeats. +//! +//! `ptbtn00f` is one of those: 105 units of ramp inside a **120**-unit cycle, so +//! it rests dark for 15 units between pulses. +//! +//! Argument, the falsification test against the measured period, and the census: +//! `docs/re/structures/ui-record-loop-length.md`. + +use std::path::PathBuf; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then_some(p) +} + +/// Read a nested record's declared length and its largest keyframe time. +fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> { + if off + 12 > bundle.len() || off + size > bundle.len() || &bundle[off..off + 4] != b"RATC" { + return None; + } + let len = u32::from_be_bytes(bundle[off + 8..off + 12].try_into().ok()?) as i64; + let lb = ui_layout::parse_build(&bundle[off..off + size])?; + let maxt = lb + .elements + .iter() + .flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) + .max()? as i64; + (maxt > 0).then_some((len, maxt)) +} + +/// The falsifier: a loop cannot restart before its own last keyframe. +#[test] +fn a_records_declared_length_is_never_shorter_than_its_keyframes() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + + let (mut total, mut exact, mut holds) = (0usize, 0usize, 0usize); + for p in &paks { + let Ok(ar) = PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { + continue; + } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for (rn, &(o, s)) in &b.records { + let Some((len, maxt)) = record_len_and_maxt(&by, o, s) else { continue }; + total += 1; + assert!( + len >= maxt, + "{}:{rn} declares a {len}-unit cycle but has a keyframe at t={maxt} — \ +a loop cannot restart before its own last pose", + p.file_name().unwrap().to_string_lossy() + ); + if len == maxt { exact += 1 } else { holds += 1 } + } + } + } + assert!(total > 1500, "expected >1500 timed nested records, got {total}"); + // The field must carry information. If every record declared exactly its own + // last keyframe time, "loop length" would be an unfalsifiable relabelling. + assert!( + holds > 50, + "only {holds} of {total} records declare a hold — the field would be carrying nothing" + ); + eprintln!("{total} records: {exact} exact, {holds} with a hold before the cycle repeats"); +} + +/// The case that motivated it, pinned by name. +#[test] +fn the_press_a_plate_glow_holds_dark_for_fifteen_units() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); + let by = ar.read(&ar.entries()[2]).expect("entry 2 — the PRESS A plate"); + let b = ui_layout::parse_build(&by).expect("parse"); + let &(o, s) = b.records.get("ptbtn00f.rat").expect("the focus record"); + let (len, maxt) = record_len_and_maxt(&by, o, s).expect("a timed record"); + assert_eq!(maxt, 105, "the glow's ramp ends at t=105"); + assert_eq!(len, 120, "but the cycle is 120 units"); + assert_eq!(len - maxt, 15, "so it holds dark for 15 units between pulses"); + + // The five main-menu focus records fill their cycle exactly — the contrast + // that shows the slack is a property of this record, not of the format. + let menu = ar.read(&ar.entries()[5]).expect("entry 5 — the main menu"); + let mb = ui_layout::parse_build(&menu).expect("parse"); + let mut checked = 0; + for n in 1..=5 { + let name = format!("ptbtn0{n}f.rat"); + let Some(&(mo, ms)) = mb.records.get(&name) else { continue }; + let (l, m) = record_len_and_maxt(&menu, mo, ms).expect("timed"); + assert_eq!((l, m), (120, 120), "{name} should fill its 120-unit cycle exactly"); + checked += 1; + } + assert_eq!(checked, 5, "expected five main-menu focus records"); +} diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 0974622e..7bce3df3 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -1870,3 +1870,60 @@ the other six. remain refuted. This finding does not answer it — it makes it cheap to get wrong. Detail, controls and reach: [`docs/re/structures/ui-tie-break-cost-at-settle.md`](../re/structures/ui-tie-break-cost-at-settle.md). + +## 2026-08-29 (later still) — the plate's pulse period is 120, not 105 + +✅ **decoded, and it answers the question you asked.** The `ptbtn00f` group holds +at alpha 0 between cycles. It does not loop from t=105. + +**A nested record is itself a RATC bundle with its own header, and that header's +`+0x08` is the loop length.** The keyframes do not have to fill it; the slack is a +hold at the final pose: + +| record | `+0x08` | largest keyframe | slack | +|---|---|---|---| +| `ptbtn00f.rat` — the plate glow | **120** | 105 | **15** | +| `ptbtn01f` … `ptbtn05f` — your main-menu focus records | 120 | 120 | 0 | +| `ptloop01` / `ptloop02` | 600 / 720 | 600 / 720 | 0 | + +So the glow ramps 0→80→0 over 105 units inside a **120-unit** cycle and rests dark +for 15. Your five menu focus records fill their cycle exactly, which is what shows +the slack belongs to this record rather than to the format. + +**Disc-wide, 1 781 timed nested records:** 92.3 % declare exactly their last +keyframe time, **7.7 % declare more**, and **0 declare less** — a cycle never +restarts before its own last pose. That last row is the falsifier and it never +fires; the 7.7 % is what stops the reading being a relabelling of the keyframes. + +**And it survives the test your objection implies.** Both candidate periods have to +be converted by the same emulator pacing factor, and that factor is measured +*independently* on your focus ring — declared 120 units, measured 2.177 s, so +**1.0885**: + +| plate period | nominal | factor needed to reach the measured 2.12–2.34 s | | +|---|---|---|---| +| 105 units | 1.750 s | 1.211 … 1.337 | 🔴 excludes the ring's 1.0885 | +| **120 units** | 2.000 s | 1.060 … 1.170 | ✅ **contains it** | + +At 120 units the predicted period is **2.177 s** against a measured 2.12–2.34 s. +**105 cannot reach that range under any pacing factor the ring also satisfies.** +The ring and the plate are different elements in different bundles measured in +separate runs; the only thing tying them together is that both declare 120. + +🔴 **So stop shipping 105.** The number is 120 and it is on the disc — not from +`exit_ramp_units`, the constant you correctly deleted, whose 129 merely happened to +fit. Your 123-vs-129 ambiguity straddled the right answer without containing it. + +⚠️ The 2.24 s mean is still ~3 % above the 2.177 s prediction. That sits inside the +spread of four wall-clock samples of a ~2 s period and is not evidence of a further +hold — I looked for one and the disc does not declare it. + +⚠️ **Scope:** this says where a cycle *ends*, not which records cycle. 92.3 % of +records declare no slack, and a one-shot build-in's length is simply its duration. +❔ The **top-level** `+0x08` is a different field and is untouched: every `GP_TITLE` +entry declares 300 while its elements end at 244–269, and no screen visibly repeats +every 5 s. + +Detail, census and the falsification test: +[`docs/re/structures/ui-record-loop-length.md`](../re/structures/ui-record-loop-length.md). + diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 82d65226..6fbc6330 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -169,3 +169,4 @@ files, which is how the same ground got covered twice. | [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~8–10 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations | | [`structures/ui-settle-time.md`](structures/ui-settle-time.md) | Which instant a "settled screen" composite depicts | ✅ **decoded**: a settled screen is **one instant every element is posed at**, and the disc names it — the midpoint of the **longest keyframe-free interval** in the build (`UiBuild::settle_time` / `settle_window`). 🔴 `rest()` is *not* that: it picks each element's last hold **independently**, so a two-frame flash holds at its **peak** and burns forever. `GP_TITLE` build 4 has five staggered flashes (`ptlogo_back2eff1`…`eff5`, all extinguished by t110) that `rest()` draws simultaneously and permanently, saturating the light arc. Predicted t=198 from `[160,236]` **before scoring**: arc band **33.22 → 11.79**, clipped pixels **8 581 → 1 452** against the console's **1 459** (an unfitted statistic), whole frame 14.07 → 12.06; controls at t=100 and t=358 are far worse, and a hand-picked visibility list reaches the identical 12.06/11.79/1 452. Controls: `at=None` byte-identical (`cmp`), pre- and post-rotation tags both 14.07, 13 paint-order tests green. ⚠️ **Reach**: of 1 758 bundles with ≥2 keyframe times only **30 %** have a window ≥ 30 units and **42 %** under 10 — mostly `loop*` fragments that never settle; check the width. 🔴 Withdraws two claims in [`ui-rotation-implemented.md`](structures/ui-rotation-implemented.md) — its "Flat. No minimum." (`at` posed **leaves only**) and its "Reborn does not draw `ptlogo1`/`ptlogo2`" (both **are** drawn; only kind-`0x4` ghosts are skipped, and hiding the real ones makes the error *worse* by +5.20/+7.47). ❔ its **10.92** baseline is unreproducible — 14.07 at both tags | | [`structures/ui-tie-break-cost-at-settle.md`](structures/ui-tie-break-cost-at-settle.md) | What the unknown paint-order tie-break costs, in pixels | ✅ **decoded**, closing the open half of Q3: at the settled instant the tie-break costs **at most 1 px at Δ1**, on the **Japanese title only** (`ptlogo2`×`ptlogo_tm`, 5 px shared ink); **exactly 0 px on all five port screens**. The earlier 24-pair bound was a `rest()` count — and 10 of the title's 11 tied pairs are between `ptlogo_back2eff1`…`eff5`, five transient flashes that are **transparent** on the settled screen ([`ui-settle-time.md`](structures/ui-settle-time.md)). Live pairs at settle: entry 4 → **1**, entry 7 → **2**, the four loading bundles → **0**. ✅ Not a knife-edge — sweeping every keyframe time and midpoint, the count is **flat across the whole settle window**, and the loading bundles' tie is live only at t17–t33. ✅ Controls: an overlapping *different*-key swap moves 25 310 / 268 698 / ~765 000 px on the entries reporting zero; zeros are explained by shared-ink counts (the `ptframe` pairs share **0 px** of ink). ⚠️ Entries 0/1/12/15 have **no live control** — their zeros rest on keyframe data, not a render. 🟡 Refutation attempt on the corpus's "24 pairs": **survives** as a rest-pose bound, 16/16 on entry 7. ❔ *Why* ties order as they do is still unknown — and now worth one pixel | +| [`structures/ui-record-loop-length.md`](structures/ui-record-loop-length.md) | Where a looping record's cycle restarts — and the `PRESS Ⓐ` plate's real period | ✅ **decoded**: a nested record is itself a RATC bundle and its header **`+0x08` is the loop length**; its keyframes need not fill it, and the slack is a hold at the final pose. Disc-wide over **1 781** timed nested records: 92.3 % declare exactly their last keyframe time, **7.7 % declare more**, and **0 declare less** — the falsifier (a cycle cannot restart before its own last pose) never fires. 🔴 **The plate's `ptbtn00f` is 105 units of ramp inside a 120-unit cycle, so it holds dark for 15 units** — the port was shipping **105**, and the answer is **120**. ✅ Falsification test against the running game, using a pacing factor measured *independently* on the focus ring (declared 120 → **2.177 s**, factor **1.0885**): to reach the corpus's measured 2.12–2.34 s, 105 units needs a factor of **1.211–1.337** (🔴 excludes the ring's) while 120 needs **1.060–1.170** (✅ contains it). Different elements, different bundles, separate runs — tied only by both declaring 120. ⚠️ Says where a cycle *ends*, not which records cycle. ❔ the **top-level** `+0x08` (300 on every `GP_TITLE` entry, elements ending at 244–269) is a different question, untouched | diff --git a/docs/re/data/record-loop-length-census.txt b/docs/re/data/record-loop-length-census.txt new file mode 100644 index 00000000..1bf6aea0 --- /dev/null +++ b/docs/re/data/record-loop-length-census.txt @@ -0,0 +1,20 @@ +nested records with timed keyframes : 1781 + +08 == max keyframe time (exact) : 1643 (92.3%) + +08 > max keyframe time (a hold) : 138 (7.7%) + +08 < max keyframe time 🔴 : 0 (0.00%) <- the falsifier + +slack (+08 - max t) distribution, most common first: + slack 0 : 1643 + slack 40 : 32 + slack 10 : 20 + slack 4 : 13 + slack 54 : 12 + slack 30 : 8 + slack 1 : 6 + slack 6 : 6 + slack 9 : 6 + slack 36 : 6 + slack 405 : 6 + slack 16 : 5 + slack 58 : 5 + slack 80 : 5 diff --git a/docs/re/structures/ui-record-loop-length.md b/docs/re/structures/ui-record-loop-length.md new file mode 100644 index 00000000..001bab7b --- /dev/null +++ b/docs/re/structures/ui-record-loop-length.md @@ -0,0 +1,106 @@ +# A nested record's `+0x08` is its loop length — and the plate holds dark for 15 units + +**Classification: decoded.** The field is read from the disc and checked +disc-wide (1 781 records, 0 violations). The consequence for the `PRESS Ⓐ` plate +is then tested against the corpus's existing measurements of the running game, +which it passes and the previous reading fails. + +## The question + +The port asked it directly: the plate's pulse group runs t=0 → t=105 with alpha 0 +at both ends. **Does it loop from its start, or hold at alpha 0 between cycles?** + +It matters because a 105-unit period is **1.750 s**, and the corpus timed the real +pulse four times at **2.12 / 2.19 / 2.34 / 2.31 s** — about 17 % longer. The port +shipped 105 anyway, saying the disc's number was wrong, because the alternative +(129) was built on an `exit_ramp_units` constant that a decode had just deleted. + +## The answer: it holds, and the period is 120 + +A nested record is **itself a RATC bundle, with its own header**, and that +header's `+0x08` is a frame count — the same field +[`ui-header-time-disc`](../../crates/sylpheed-formats/tests/ui_header_time_disc.rs) +already tests as an animation length at the top level. Read it on the record and +the answer is immediate: + +| record | `+0x08` | largest keyframe | slack | +|---|---|---|---| +| `ptbtn00f.rat` — the plate glow | **120** | 105 | **15** | +| `ptbtn01f` … `ptbtn05f` — main menu focus | 120 | 120 | 0 | +| `ptloop01.rat` | 600 | 600 | 0 | +| `ptloop02.rat` | 720 | 720 | 0 | + +**The glow ramps 0 → 80 → 0 over 105 units inside a 120-unit cycle, so it rests +dark for 15 units between pulses.** The five main-menu focus records fill their +cycle exactly, which is what shows the slack is a property of *this record* rather +than of the format. + +## Disc-wide + +[`record-loop-length-census.txt`](../data/record-loop-length-census.txt) — every +nested record on the disc that has a timed keyframe: + +| | count | share | +|---|---|---| +| nested records with timed keyframes | 1 781 | — | +| `+0x08 == ` largest keyframe time | 1 643 | 92.3 % | +| `+0x08 > ` largest keyframe time (a hold) | 138 | 7.7 % | +| **`+0x08 < ` largest keyframe time** | **0** | **0.00 %** | + +The last row is the falsifier and it never fires: **no record declares a cycle +that would restart before its own last pose.** The 7.7 % is what keeps the reading +from being an unfalsifiable relabelling of the keyframes — if every record +declared exactly its own last keyframe time, the field would carry nothing. + +## The falsification test against the running game + +Both hypotheses are periods in declared units, and both have to be converted by +the same emulator pacing factor. That factor is measured **independently**, on the +main menu's focus ring: declared 120 units, measured **2.177 s** +([`focus-ring-spin-measured.md`](../focus-ring-spin-measured.md)), so the factor +is **1.0885** against a nominal 60 units/s. + +| plate period | nominal | factor it would need to reach 2.12–2.34 s | verdict | +|---|---|---|---| +| 105 units | 1.750 s | **1.211 … 1.337** | 🔴 **excludes** the ring's 1.0885 | +| **120 units** | 2.000 s | **1.060 … 1.170** | ✅ **contains** the ring's 1.0885 | + +At 120 units and the ring's own factor the plate should pulse every **2.177 s**, +against a measured 2.12–2.34 s. **105 cannot reach the measured range under any +pacing factor that the ring also satisfies.** + +This is a genuine test rather than a fit: the ring and the plate are different +elements in different bundles, measured in separate runs, and the only thing tying +them together is that both declare a 120-unit cycle. + +## What this replaces + +* 🔴 **105 is wrong** and the port should stop shipping it. The number is **120**, + and it comes from the disc — not from `exit_ramp_units`, the deleted constant + whose 129 happened to fit. +* ✅ **The port's ambiguity is genuinely resolved**, just not the way it read: the + old 123-vs-129 pair straddled the right answer without containing it. +* ⚠️ **The 2.24 s mean is still ~3 % above the 2.177 s prediction.** That is inside + the spread of the four measurements (2.12–2.34) and is not evidence of a further + hold; it is what a four-sample wall-clock measurement of a ~2 s period in this + emulator looks like. + +## Reach + +⚠️ **This says where a cycle ends, not that every record cycles.** 92.3 % of +records declare no slack at all, and a record whose element is not in a repeating +state (a button's base record, a one-shot build-in) has a length that is simply +its own duration. Nothing here establishes *which* records the game restarts — +only that when one does, `+0x08` is where. + +❔ **The top-level `+0x08` is not the same thing.** Every `GP_TITLE` entry declares +300 there while its elements end at 244–269, and no screen visibly repeats every +5 s. Whether the top-level field is a loop length, a budget or something else is +untouched by this. + +## Reproducing + +```bash +cargo run -p sylpheed-formats --example record_loop_length +SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test ui_record_loop_length_disc +```