diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 1154a092..3a000717 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -111,12 +111,22 @@ pub struct Keyframe { /// starts are negative. pub x: i32, pub y: i32, - /// Keyframe time, or `None` for the group's **last** frame. + /// The time at which this pose is reached. /// - /// A group's data stops 4 bytes short of its final block's time slot — that - /// word is already the next group's element index. Reading it anyway is - /// where a stray `time = 1869640736` comes from, and it silently corrupts - /// the max-dwell pick in [`Element::rest`]. + /// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header + /// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time + /// word **precedes** the pose it belongs to. Our block window starts at the + /// pose, so pose `k`'s time is the previous stride's `+36` word, and pose + /// 0's is the group's lead-in word at `header + 8`. + /// + /// ⚠️ The old reading took `+36` as *this* pose's time. That left the final + /// pose — the end of every fade-out — untimed, and it is where the "a + /// group's data stops 4 bytes short of its final block's time slot" note and + /// the stray `time = 1869640736` both came from. There is no short group and + /// no missing word; the association was off by one. + /// See `docs/re/ui-keyframe-record-layout.md`. + /// + /// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch. pub time: Option, } @@ -493,8 +503,10 @@ fn mark_focused_states(elements: &mut [Element]) { /// Read the placement region that follows the declaration table, filling in each /// element's keyframe group. fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { - // Experiment gate, default off; see the `time` field below. - let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1"); + // Escape hatch for the pre-2026-08-29 reading, which mis-associated every + // keyframe time by one slot and could not time a group's final pose at all. + // See `docs/re/ui-keyframe-record-layout.md`. + let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1"); let count = elements.len(); let mut order = Vec::with_capacity(count); let mut pos = DECL_TABLE_AT + count * DECL_ENTRY; @@ -507,7 +519,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { if idx >= count || frames == 0 || frames > 4096 { break; } - // Group header is (index, count) then one lead-in word; blocks follow. + // The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte + // pose }`, after an 8-byte header — so the word at `pos + 8` is the + // FIRST pose's time, and each 40-byte stride's `+36` word is the time of + // the pose that follows it. Our block window is offset 4 bytes into the + // record (it starts at the pose), which is why the pose field offsets + // below are right while the times were off by one. + let first_time = be32(bundle, pos + 8); let first = pos + 12; // The region is packed so that the next group's header sits 4 bytes // inside the last block — i.e. the group owns `frames * 40 - 4` bytes of @@ -529,16 +547,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { tint: be32(bundle, blk + 24), x: be32(bundle, blk + 28) as i32, y: be32(bundle, blk + 32) as i32, - // Only a block wholly inside the group carries a time. - // - // ⚠️ Which block a time word BELONGS TO is under test — see - // `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1` - // to read `W[k-1]` as block `k`'s time ("the word is the time the - // NEXT pose is reached") instead of `W[k]`. Default is unchanged. - time: if shift_times { - (k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36)) - } else { + // Pose `k`'s time is the word that PRECEDES it: the group's + // lead-in word for `k == 0`, and the previous stride's `+36` + // otherwise. Every pose is timed; nothing is missing and nothing + // is special-cased. Checked disc-wide — see + // `docs/re/ui-keyframe-record-layout.md`. + time: if legacy_times { (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)) + } else if k == 0 { + Some(first_time) + } else { + Some(be32(bundle, blk - KEYFRAME + 36)) }, }); } diff --git a/crates/sylpheed-formats/tests/ui_header_time_disc.rs b/crates/sylpheed-formats/tests/ui_header_time_disc.rs index cc3d8d76..11753851 100644 --- a/crates/sylpheed-formats/tests/ui_header_time_disc.rs +++ b/crates/sylpheed-formats/tests/ui_header_time_disc.rs @@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() { worst.push(format!("{pak}: max keyframe {max_time} > header {dur}")); } } - let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32; - *ratio.entry(r.min(30)).or_default() += 1; + // ⚠️ Bundles whose every group is a single static pose contribute + // `max_time == 0` and say nothing about whether `+0x08` is a length. + // Before 2026-08-29 they were invisible here, because the old keyframe + // time reading left a one-frame group's only pose untimed; the corrected + // record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the + // group's lead-in time, which is 0. They are excluded rather than + // allowed to swamp the histogram's zero bucket — 546 of them do. + if max_time > 0 { + let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32; + *ratio.entry(r.min(30)).or_default() += 1; + } }); eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}"); @@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() { assert!(animated > 0, "no animated bundles — the sweep is broken"); - // MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the - // 2313 bundles that have both, and 444 of them reach it exactly. The + // MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe + // record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859 + // bundles that have both (2 313 before the correction, which could not time + // a group's final pose at all), and 444 of them reach it exactly. The // spread-out ratio histogram is what rules out the boring explanation: a // large unrelated constant would bound everything too, but then the ratios // would pile up near zero instead of peaking at 1.0. + // + // ✅ The correction STRENGTHENS this result rather than weakening it: 546 + // more bundles now carry a readable last-pose time, and `over` is still 0 — + // i.e. the newly-visible times, which are the LATEST in every group, still + // do not run past the header's. assert_eq!(over, 0, "a keyframe time runs past the header's +0x08"); assert!(exact > 400, "the bound is never attained — it may be unrelated"); let near_one = ratio.get(&10).copied().unwrap_or(0); diff --git a/crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs b/crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs new file mode 100644 index 00000000..fc22b1bc --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs @@ -0,0 +1,204 @@ +//! A placement group's time word **precedes** the pose it belongs to. +//! +//! A group is an 8-byte header `{u32 element_index, u32 frame_count}` followed +//! by `frame_count` records of 40 bytes, each `{u32 time; 36-byte pose}`. The +//! parser's block window starts at the *pose*, four bytes into the record, so +//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the +//! first pose's time is the group's lead-in word at `header + 8`. +//! +//! The old reading took `+36` as the block's own time. That is off by one, and +//! it is where two long-standing oddities came from: the group looked four bytes +//! short, and the final pose — the end of every fade-out — carried no time. +//! +//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`. +//! +//! Two checks here, both disc-wide: +//! +//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old +//! reading the last pose has no time at all, so this cannot even be asked. +//! 2. **A monotone alpha ramp of three or more segments runs at a constant +//! rate.** Interpolation between keyframes is linear +//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes +//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old +//! reading achieves this on **zero** ramps on the whole disc. + +use std::path::{Path, PathBuf}; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + let default = Path::new( + "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", + ); + if default.join("dat").is_dir() { + return Some(default.to_path_buf()); + } + None +} + +fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) { + 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(); + for p in &paks { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let Ok(arc) = PakArchive::open(p) else { continue }; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + if ratc::is_ratc(&bytes) { + f(&name, &bytes); + } + } + } +} + +/// Maximal runs of strictly monotone alpha with at least `min_seg` segments. +fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + let (mut i, n) = (0usize, alphas.len()); + while i + 1 < n { + if alphas[i] == alphas[i + 1] { + i += 1; + continue; + } + let up = alphas[i + 1] > alphas[i]; + let mut j = i + 1; + while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] { + j += 1; + } + if j - i >= min_seg { + out.push((i, j)); + } + i = j; + } + out +} + +fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option { + let mut rates = Vec::new(); + for k in a..b { + let dt = times[k + 1].checked_sub(times[k])?; + if dt == 0 { + return None; + } + rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64); + } + let mean = rates.iter().sum::() / rates.len() as f64; + if mean == 0.0 { + return None; + } + let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max); + Some(worst / mean <= 0.06) +} + +#[test] +fn every_pose_is_timed_and_the_times_are_ordered() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") { + eprintln!("SKIP: the legacy time reading is selected"); + return; + } + + let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize); + let mut examples: Vec = Vec::new(); + for_each_build(&root, |pak, bytes| { + let Some(build) = ui_layout::parse_build(bytes) else { + return; + }; + if build.from_fallback { + return; + } + for el in &build.elements { + if el.keyframes.is_empty() { + continue; + } + groups += 1; + if el.keyframes.iter().any(|k| k.time.is_none()) { + untimed += 1; + if examples.len() < 8 { + examples.push(format!("{pak}: {} has an untimed pose", el.name)); + } + continue; + } + let t: Vec = el.keyframes.iter().map(|k| k.time.unwrap()).collect(); + if t.windows(2).any(|w| w[1] < w[0]) { + out_of_order += 1; + if examples.len() < 8 { + examples.push(format!("{pak}: {} times {t:?} descend", el.name)); + } + } + } + }); + + eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}"); + for e in &examples { + eprintln!(" {e}"); + } + assert!(groups > 10_000, "expected the whole disc, saw {groups} groups"); + assert_eq!(untimed, 0, "every pose must carry a time"); + assert_eq!(out_of_order, 0, "keyframe times must not descend"); +} + +#[test] +fn multi_segment_alpha_ramps_run_at_a_constant_rate() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") { + eprintln!("SKIP: the legacy time reading is selected"); + return; + } + + let (mut ramps, mut constant) = (0usize, 0usize); + for_each_build(&root, |_pak, bytes| { + let Some(build) = ui_layout::parse_build(bytes) else { + return; + }; + if build.from_fallback { + return; + } + for el in &build.elements { + if el.keyframes.iter().any(|k| k.time.is_none()) { + continue; + } + let t: Vec = el.keyframes.iter().map(|k| k.time.unwrap()).collect(); + let a: Vec = el + .keyframes + .iter() + .map(|k| ((k.fade >> 24) & 0xff) as i32) + .collect(); + for (lo, hi) in monotone_ramps(&a, 3) { + if let Some(ok) = constant_rate(&t, &a, lo, hi) { + ramps += 1; + constant += usize::from(ok); + } + } + } + }); + + let share = 100.0 * constant as f64 / ramps as f64; + eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)"); + assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps"); + // The old reading scores 0 of 1042. Half is a floor, not a target: the rest + // are genuinely shaped ramps, authored with keyframes that are not evenly + // spaced. Anything near zero means the time assignment has slipped again. + assert!( + share > 45.0, + "only {share:.1}% of ramps run at a constant rate — the time \ + association has probably slipped (the old off-by-one scored 0%)" + ); +} diff --git a/docs/re/data/kf-record-census.txt b/docs/re/data/kf-record-census.txt new file mode 100644 index 00000000..cf2a425d --- /dev/null +++ b/docs/re/data/kf-record-census.txt @@ -0,0 +1,14 @@ +paks scanned : 33 +placement groups : 13991 + +A. lead-in prepended to the shifted times is non-decreasing + 13991/13991 = 100.000% + +B. non-zero lead-in is strictly less than the next time + 5058/5058 = 100.000% + control (another group's lead-in, same bundle): 35837/50580 = 70.852% + gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)] + +C. constant d(alpha)/d(time) across a multi-segment ramp + corrected (time precedes pose): 857/1540 = 55.649% + old (+36 is own time) : 0/1042 = 0.000% diff --git a/docs/re/ui-keyframe-record-layout.md b/docs/re/ui-keyframe-record-layout.md new file mode 100644 index 00000000..88552e91 --- /dev/null +++ b/docs/re/ui-keyframe-record-layout.md @@ -0,0 +1,224 @@ +# A keyframe's time word comes **before** its pose — the placement record, decoded + +**Status:** ✅ `CONFIRMED`, **decoded**. The field, plus a disc-wide check +(13 991 placement groups over 33 archives, three tests, each with a control) and +a regression test that runs against the disc +(`crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs`). + +This closes the one thing [MISSION](../port/MISSION.md) **Q1** still had open — +*"the interpolation law is settled; the group TIMELINE for multi-keyframe +elements is not"* — and it dissolves, rather than decides, the argument in +[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) about whether to adopt +`SYLPHEED_KF_TIME_SHIFT`. Both sides of that argument were reasoning about a +missing word that is not missing. + +## The record + +A build bundle's placement region is a run of groups, one per element. A group +is an 8-byte header followed by `frame_count` **records of 40 bytes**: + +```text +u32 element_index +u32 frame_count + ┐ +u32 time │ record 0 ← the time comes FIRST +36 pose ┘ +u32 time ┐ record 1 +36 pose ┘ +… +u32 time ┐ record n−1 +36 pose ┘ +``` + +Total group size: `8 + frame_count * 40`. + +The 36-byte pose is what the parser already reads correctly — fade ARGB, the +three signed rotation words, scale X/Y, tint, X, Y — at offsets 0…35 of the +pose, i.e. 4…39 of the record. + +## What was wrong, and why it looked right for so long + +Our parser opened its 40-byte window **at the pose**, four bytes into record 0, +and then read the word at window `+36` as that pose's time. That word is +record `k+1`'s `time` — the time of the *next* pose. Every pose field lands +correctly (the window is aligned to a pose, and poses are what it reads); only +the time association slips by one. + +Two long-standing oddities in the corpus are that off-by-one, and nothing else: + +| the oddity as recorded | what it actually was | +|---|---| +| *"a group's data stops 4 bytes short of its final block's time slot — that word is already the next group's element index"* | the group is **not** short. `8 + frames*40` is exact. The parser was reading 4 bytes past the last pose because its window began 4 bytes early | +| *"the last keyframe carries no time"* — `Keyframe::time` was `Option`, `None` on every group's final pose | the final pose's time is the *previous* stride's `+36` word. **Every** pose is timed | +| the stray `time = 1869640736` (= `"ohnm"`, ASCII from the next record) that "silently corrupts the max-dwell pick in `Element::rest`" | the same over-read | + +The first pose's time is the group's **lead-in word** at `header + 8` — the word +[`parse_placements`](../../crates/sylpheed-formats/src/ui_layout.rs) skipped as +*"one lead-in word"* without asking what it was. + +## The disc-wide check + +`tools/re-capture/kf_record_census.py`, output committed at +[`data/kf-record-census.txt`](data/kf-record-census.txt). Run it with + +```bash +python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak +``` + +### A. The lead-in word takes its place in the sequence + +Prepending the lead-in to the shifted time series must give a non-decreasing +sequence. **13 991 of 13 991 groups — 100.000 %.** (15 493 including +single-pose groups, which are trivially ordered; the regression test counts +those and also finds 0 out of order.) + +### B. The 5 058 non-zero lead-ins are times, not padding + +If the lead-in were padding, a flag, or a count, 5 058 of them would not all +happen to fall strictly below the group's next time. + +| | result | +|---|---| +| non-zero lead-ins | 5 058 | +| strictly less than the next time | **5 058 — 100.000 %**, none equal | +| **control**: another group's lead-in from the same bundle | 35 837 / 50 580 = **70.9 %** | + +The gap to the next time piles up at **10** (2 076 groups) and **1** (2 022) — +ramp lengths, not arbitrary numbers. And the values themselves read as times: +`GP_DIALOG` entry 9's `pzeff02.t32` runs `167 → 197 → 217 → 232`; entry 25's +`pznoise.rat` runs `40 → 80 → 230 → 260`. + +### C. A multi-keyframe ramp only runs at a constant rate under this reading + +Interpolation between two keyframes is linear — measured against the running +game, in [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md). So where an +author chains three or more keyframes through a monotone alpha ramp, a correct +time assignment should often make `d(alpha)/d(time)` come out constant, and a +wrong one should scramble it. + +| reading | multi-segment alpha ramps at a constant rate (±6 %) | +|---|---| +| **corrected** — time precedes pose | **857 / 1 540 = 55.6 %** | +| old — `+36` is the block's own time | **0 / 1 042 = 0.0 %** | + +**Zero.** Not one ramp on the whole disc. The 44 % that are not constant under +the corrected reading are genuinely shaped ramps — authors do place keyframes +unevenly — so 56 % is a floor, not a fit. + +A worked example, `pgloading_loop4.rat` on `GP_TITLE` build 11: + +| | times | alphas | rate per unit | +|---|---|---|---| +| corrected | 0, 4, 6, 7, 8, 32, 38 | 0, 128, 192, 224, 255, 255, 0 | **32, 32, 32, 31** — then hold, then out | +| old | 4, 6, 7, 8, 32, 38, *(none)* | 0, 128, 192, 224, 255, 255, 0 | 64, 64, 32, 1.3 — then hold, then an **untimed** fade-out | + +## What it costs to adopt: nothing, on every static composite + +This is the change the corpus previously declined to make, because +`SYLPHEED_KF_TIME_SHIFT=1` moved `GP_TITLE` build 7 by 13.1 % of its pixels and +made the EN/JP twin brightness disagree (70.94 vs 76.32 against build 4's +71.41). **That was the missing first time word, not the shift.** + +With the lead-in restored as pose 0's time: + +| check | result | +|---|---| +| `GP_TITLE`, all 12 builds rendered under both readings | **12 / 12 byte-identical PNGs**, build 7 included | +| 217 builds over 6 UI archives, `rest()` pose per element | **2 builds differ**: `GP_TITLE` 7 and `GP_DIALOG` 31 | +| what those 2 differences are | `ptlogo_eff3.t32`: `(98,42)` vs `(108,72)` — **both α = 0**, so neither paints. `pzstg14_2.t32`: one pixel of Y | +| renders of those 2 builds | **identical** | + +So the build-7 luminance objection is withdrawn: it was `rest()`'s dwell +fallback picking the 200 %-scale bloom because pose 0 had no time to be compared +against. Given a time, the dwell rule picks an invisible pose — the same +*visible* result the old reading produced, by a rule that is now sound. + +⚠️ **`Element::rest()` is unchanged and is still a heuristic.** The times feed +it; they do not fix it. `structures/ui-resting-pose.md` stands as written. + +## Against the oracle + +The committed `log_ui_draws` capture of the developer splash +([`captures/ui-timing/splash-build-quads.csv`](captures/ui-timing/splash-build-quads.csv)) +is the check that this is the game's reading and not merely a tidier one. + +`palogo_gamearts_eff.t32` — lead-in 0, `W = [15, 30, 45, –]`, alphas +`[0, 255, 255, 0]`: + +| phase | corrected | old | captured | +|---|---|---|---| +| fade in | t 0→15 (7.5 f) | t 15→30 (7.5 f) | frames 94–101, **7 f** | +| hold | t 15→30 (7.5 f) | t 30→45 (7.5 f) | frames 101–107, **7 f** | +| fade out | t 30→45 (7.5 f) | **untimed** | frames 108–115, **8 f** | + +The glow's *durations* do not discriminate — that was already recorded — but its +**end does**: the old reading cannot say when the fade-out finishes, and the +capture plainly shows it finishing. + +`palogo_gamearts.t32` — lead-in 0, `W = [15, 30, 190, 194, 206, 210, –]`, alphas +`[0, 0, 255, 255, 232, 32, 0]`: + +| | corrected | old | captured | +|---|---|---|---| +| fade in | t 15→30, **7.5 f**, in the same window as its own glow | t 30→190, **80 f** | already at 255 when the quad first appears (frame 116) | +| hold at 255 | t 30→190, **80 f** | t 190→194, **2 f** | frames ≤116–198, **≥ 83 f** | +| fade out | t 190→210, **10 f** | t 194→? , untimed end | frames 199–211, **13 f** | + +A logo whose bloom layer fades in over 7.5 frames while the logo itself takes 80 +is not a thing anyone authored. This replicates the 26× result already in +[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) and adds the reason. + +⚠️ **Reach.** The capture's absolute frame numbers sit about 18 frames later than +the glow-derived calibration `t = 2f − 171` predicts for the *logo* — the +fade-out starts at frame 199 where the calibration says 180.5. Durations match; +the two elements' groups do not appear to start on the same frame. That offset is +**not explained here** and is not needed for this result, which is about which +word is which. It is the same lateness `ui-keyframe-time-unit.md` records as +"17 frames late" and leaves open. + +## What changed in the code + +[`crates/sylpheed-formats/src/ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs): + +* `parse_placements` reads `header + 8` as pose 0's time and the previous + stride's `+36` as pose `k`'s. Every pose gets a time. +* `SYLPHEED_KF_TIME_SHIFT` is gone. `SYLPHEED_KF_TIME_LEGACY=1` restores the old + reading for A/B work. +* `Keyframe::time` stays `Option` only so the legacy gate still type-checks. + Under the default it is always `Some`. + +New test, disc-gated: `tests/ui_keyframe_record_disc.rs` — every pose timed and +ordered (15 493 groups), and ≥ 45 % of multi-segment alpha ramps at a constant +rate (the old reading scores 0 %). + +⚠️ `tests/ui_header_time_disc.rs` needed one line: bundles whose every group is a +single static pose now report `max_time = 0` where before they reported no time +at all, and 546 of them were swamping the ratio histogram's zero bucket. The +result it guards **strengthened** — the bound `max_time ≤ header +0x08` now holds +over **2 859** bundles instead of 2 313, still with **0** violations, and the +newly readable times are the latest in every group. + +## What is NOT established + +❔ **The executable's own parser was not found.** Reach: queried the disassembly +database for functions carrying a `mulli` by 40 (the record stride) and by 60 +(the declaration-entry stride) — 26 functions have the first, none have both, and +PowerPC compilers synthesise both constants as shift-adds, so the query is weak +rather than negative. Nothing here rests on a database row; every number above +comes from the disc bytes or from a committed capture. Finding the interpolator +would upgrade this from *decoded from the container's own arithmetic and a +disc-wide census* to *decoded from the code*, and would also settle the 18-frame +group-start offset above. + +## For the port + +The pose values you already have do not move. What moves is **when** each pose is +reached: + +* pose `k`'s time is the word **before** it, not after it; +* pose 0 has a time — usually 0, but 5 058 groups on the disc start late; +* the **last** pose has a time, so an exit ramp now has an end. Anything you + authored to cover "the final keyframe has no time" can come out. + +Static composites are unaffected: `screen render` produces byte-identical output +on all 12 `GP_TITLE` builds. diff --git a/tools/re-capture/kf_record_census.py b/tools/re-capture/kf_record_census.py new file mode 100755 index 00000000..8a871b0c --- /dev/null +++ b/tools/re-capture/kf_record_census.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Disc-wide test of the UI placement-group record layout. + +A placement group in a screen bundle is an 8-byte header `{u32 element_index, +u32 frame_count}` followed by `frame_count` records of 40 bytes, each +`{u32 time; 36-byte pose}`. The time word therefore **precedes** the pose it +belongs to. + +Our parser's block window starts at the pose, so under the old reading a +block's `+36` word was taken as *its own* time — off by one, which left the +group's final pose (the end of every fade-out) untimed and made the group look +4 bytes short. + +This script tests the corrected reading against the whole disc, with controls: + + A. monotonicity — prepending the lead-in word to the shifted time series must + give a non-decreasing sequence, for every group. + B. the non-zero lead-ins — a lead-in that is a *time* must be strictly less + than the next time. Control: swap in another group's lead-in from the + same bundle. + C. ramp linearity — within a monotone alpha ramp of >=3 segments, is + d(alpha)/d(time) constant? Keyframe interpolation is linear + (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment should + make multi-keyframe ramps come out at a constant rate far more often than + an incorrect one. + +Usage: python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak +""" +import collections +import os +import random +import struct +import sys +import zlib + + +def be32(b, o): + return struct.unpack_from(">I", b, o)[0] + + +def load_pak(pak_path): + """Entries of an IPFB archive, transparently un-Z1'd. See sylpheed-formats::pak.""" + idx = open(pak_path, "rb").read() + if idx[:4] != b"IPFB": + return [] + n = be32(idx, 4) + data = b"" + base = os.path.splitext(pak_path)[0] + for i in range(100): + p = f"{base}.p{i:02d}" + if not os.path.exists(p): + break + data += open(p, "rb").read() + out = [] + for k in range(n): + _h, off, sz = struct.unpack_from(">III", idx, 0x10 + 12 * k) + blob = data[off : off + sz] + if blob[:2] == b"Z1": + try: + blob = zlib.decompress(blob[0x0A:]) + except Exception: + blob = b"" + out.append(blob) + return out + + +def elem_name(b, o): + s = b[o : o + 28] + z = s.find(b"\0") + return (s[:z] if z >= 0 else s).decode("latin1") + + +def groups(bundle): + """(element_index, name, frames, lead_in, W, alphas) per placement group. + + `W[k]` is the word at the k-th 40-byte stride's `+36`; `W[-1]` is None + because it lies outside the group. Under the corrected reading the pose + times are `[lead_in] + W[:-1]`. + """ + n = be32(bundle, 0x14) + names = [elem_name(bundle, 0x20 + i * 60) for i in range(n)] + pos = 0x20 + n * 60 + out = [] + for _ in range(n): + if pos + 8 > len(bundle): + break + idx, frames = be32(bundle, pos), be32(bundle, pos + 4) + if idx >= n or frames == 0 or frames > 4096: + break + lead_in = be32(bundle, pos + 8) + first = pos + 12 + end = first + frames * 40 - 4 + if end > len(bundle): + break + W, alphas = [], [] + for k in range(frames): + blk = first + k * 40 + W.append(be32(bundle, blk + 36) if blk + 40 <= end else None) + alphas.append(be32(bundle, blk) >> 24) + out.append((idx, names[idx], frames, lead_in, W, alphas)) + pos = end + return out + + +def is_build(raw): + if raw[:4] != b"RATC" or len(raw) < 0x20: + return False + n = be32(raw, 0x14) + return 0 < n <= 4096 and 0x20 + n * 60 <= len(raw) + + +def monotone_ramps(alphas, min_segments=3): + out, i, n = [], 0, len(alphas) + while i < n - 1: + if alphas[i] == alphas[i + 1]: + i += 1 + continue + d = 1 if alphas[i + 1] > alphas[i] else -1 + j = i + 1 + while j < n - 1 and (alphas[j + 1] - alphas[j]) * d > 0: + j += 1 + if j - i >= min_segments: + out.append((i, j)) + i = j + return out + + +def constant_rate(times, alphas, a, b, tol=0.06): + rates = [] + for k in range(a, b): + dt = times[k + 1] - times[k] + if dt <= 0: + return None + rates.append(abs(alphas[k + 1] - alphas[k]) / dt) + mean = sum(rates) / len(rates) + if mean == 0: + return None + return max(abs(r - mean) for r in rates) / mean <= tol + + +def main(paks): + rnd = random.Random(20260829) + per_bundle = collections.defaultdict(list) + total = mono = 0 + ramp_new = [0, 0] + ramp_old = [0, 0] + for p in paks: + for ei, raw in enumerate(load_pak(p)): + if not is_build(raw): + continue + try: + gs = groups(raw) + except Exception: + continue + for _idx, nm, _frames, lead_in, W, alphas in gs: + rest = W[:-1] + if not rest or any(w is None for w in rest): + continue + total += 1 + t_new = [lead_in] + rest + if all(t_new[i] <= t_new[i + 1] for i in range(len(t_new) - 1)): + mono += 1 + per_bundle[(os.path.basename(p), ei)].append((lead_in, rest, nm)) + for a, b in monotone_ramps(alphas): + r = constant_rate(t_new, alphas, a, b) + if r is not None: + ramp_new[1] += 1 + ramp_new[0] += r + # old reading: `+36` is the block's own time, last pose untimed + a_old = alphas[:-1] + for a, b in monotone_ramps(a_old): + r = constant_rate(rest, a_old, a, b) + if r is not None: + ramp_old[1] += 1 + ramp_old[0] += r + + nz = nz_ok = ctrl = ctrl_ok = 0 + gaps = collections.Counter() + for rows in per_bundle.values(): + pool = [l for l, _, _ in rows] + for lead_in, rest, _nm in rows: + if lead_in == 0: + continue + nz += 1 + nz_ok += lead_in < rest[0] + gaps[rest[0] - lead_in] += 1 + for _ in range(10): + ctrl += 1 + ctrl_ok += rnd.choice(pool) < rest[0] + + pct = lambda a, b: f"{100 * a / b:.3f}%" if b else "n/a" + print(f"paks scanned : {len(paks)}") + print(f"placement groups : {total}") + print() + print("A. lead-in prepended to the shifted times is non-decreasing") + print(f" {mono}/{total} = {pct(mono, total)}") + print() + print("B. non-zero lead-in is strictly less than the next time") + print(f" {nz_ok}/{nz} = {pct(nz_ok, nz)}") + print(f" control (another group's lead-in, same bundle): " + f"{ctrl_ok}/{ctrl} = {pct(ctrl_ok, ctrl)}") + print(f" gap to the next time, most common: {gaps.most_common(8)}") + print() + print("C. constant d(alpha)/d(time) across a multi-segment ramp") + print(f" corrected (time precedes pose): {ramp_new[0]}/{ramp_new[1]} = " + f"{pct(ramp_new[0], ramp_new[1])}") + print(f" old (+36 is own time) : {ramp_old[0]}/{ramp_old[1]} = " + f"{pct(ramp_old[0], ramp_old[1])}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit(__doc__) + main(sys.argv[1:])