diff --git a/crates/sylpheed-formats/examples/kf_flip_test.rs b/crates/sylpheed-formats/examples/kf_flip_test.rs new file mode 100644 index 00000000..02f07d85 --- /dev/null +++ b/crates/sylpheed-formats/examples/kf_flip_test.rs @@ -0,0 +1,72 @@ +//! Do `+4` / `+8` = 180 mean MIRROR? +//! +//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ยฑ90, while +//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including +//! odd ones. That shape says flips rather than free rotation. +//! +//! Structural test, no renderer involved: if 180 means "mirror", then the same +//! sprite should appear both with the field 0 and with it 180 **within one +//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing. +//! +//! CONTROL: the same search run on `+12`, which is decoded as a real rotation +//! and should NOT show a 0/180 pairing pattern of the same strength. +use sylpheed_formats::{pak, ui_layout}; +use std::collections::{BTreeMap, BTreeSet}; + +fn main() { + let dir = std::env::args().nth(1).expect("/dat"); + let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect(); + paks.sort(); + + // field -> (pak, entry, sprite) -> set of values seen + let mut seen: [BTreeMap<(String, usize, String), BTreeSet>; 3] = + [BTreeMap::new(), BTreeMap::new(), BTreeMap::new()]; + + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + let pn = p.file_name().unwrap().to_string_lossy().to_string(); + for (i, e) in ar.entries().to_vec().iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&bytes) else { continue }; + let mut groups: Vec<(String, Vec)> = + b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect(); + for el in &b.elements { + if let Some(&(off, size)) = b.records.get(&el.name) { + if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) { + for le in &lb.elements { + groups.push((le.name.clone(), le.keyframes.clone())); + } + } + } + } + for (nm, ks) in groups { + for k in &ks { + let key = (pn.clone(), i, nm.clone()); + seen[0].entry(key.clone()).or_default().insert(k.unknown_4); + seen[1].entry(key.clone()).or_default().insert(k.unknown_8); + seen[2].entry(key).or_default().insert(k.rotation_deg); + } + } + } + } + + for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] { + let m = &seen[idx]; + let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build + let mut only_180 = 0usize; + let mut multi = 0usize; // more than two distinct values + for v in m.values() { + if v.len() > 2 { multi += 1; } + let has0 = v.contains(&0); + let has180 = v.contains(&180) || v.contains(&-180); + if has0 && has180 { pair_0_180 += 1; } + else if has180 && !has0 { only_180 += 1; } + } + println!("{label}: {} sprite-instances", m.len()); + println!(" both 0 and ยฑ180 in one build : {pair_0_180}"); + println!(" ยฑ180 without any 0 : {only_180}"); + println!(" more than 2 distinct values : {multi}"); + } +} diff --git a/crates/sylpheed-formats/examples/kf_unknown_census.rs b/crates/sylpheed-formats/examples/kf_unknown_census.rs new file mode 100644 index 00000000..0a79f6dc --- /dev/null +++ b/crates/sylpheed-formats/examples/kf_unknown_census.rs @@ -0,0 +1,83 @@ +//! What are the keyframe block's `+4` and `+8`? +//! +//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit +//! immediately before it and are carried but unexplained; one standing ๐ŸŸก +//! reading is that the three together are rotations about three axes, "not tied +//! to an observed rotation". This censuses them across every UI pak on the disc +//! so the reading can be argued with rather than assumed. +use sylpheed_formats::{pak, ui_layout}; +use std::collections::BTreeMap; + +fn main() { + let dir = std::env::args().nth(1).expect("usage: /dat"); + let mut paks: Vec<_> = std::fs::read_dir(&dir) + .expect("dir") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)) + .collect(); + paks.sort(); + + let mut h4: BTreeMap = BTreeMap::new(); + let mut h8: BTreeMap = BTreeMap::new(); + let mut h12: BTreeMap = BTreeMap::new(); + let mut both_nz: Vec = Vec::new(); + let (mut kfs, mut builds) = (0usize, 0usize); + + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + let name = p.file_name().unwrap().to_string_lossy().to_string(); + for (i, e) in ar.entries().to_vec().iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&bytes) else { continue }; + builds += 1; + // parents and leaves alike + let mut groups: Vec<(String, Vec)> = b + .elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect(); + for el in &b.elements { + if let Some(&(off, size)) = b.records.get(&el.name) { + if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) { + for le in &lb.elements { + groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone())); + } + } + } + } + for (nm, ks) in groups { + for k in &ks { + kfs += 1; + *h4.entry(k.unknown_4).or_default() += 1; + *h8.entry(k.unknown_8).or_default() += 1; + *h12.entry(k.rotation_deg).or_default() += 1; + if k.unknown_4 != 0 || k.unknown_8 != 0 { + both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}", + k.unknown_4, k.unknown_8, k.rotation_deg)); + } + } + } + } + } + println!("{builds} builds, {kfs} keyframes (parents + leaves)\n"); + for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] { + let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum(); + println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)", + h.len(), nz, 100.0 * nz as f64 / kfs as f64); + let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect(); + top.sort_by_key(|(_, v)| std::cmp::Reverse(**v)); + for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); } + } + println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len()); + // Per-pak, so a reader can ask whether a pak they have a CAPTURE of is + // among them -- which decides whether the field is testable at all. + let mut per: BTreeMap = BTreeMap::new(); + for l in &both_nz { + let pak = l.split_whitespace().next().unwrap_or("?").to_string(); + *per.entry(pak).or_default() += 1; + } + println!(" by pak:"); + for (k, v) in &per { println!(" {k:<28} {v}"); } + println!(" paks with NONE: (any UI pak not listed above)"); + if let Ok(f) = std::env::var("KF_SHOW") { + println!("\n all lines for {f}:"); + for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); } + } +} diff --git a/docs/re/data/kf-unknown-4-8-census.txt b/docs/re/data/kf-unknown-4-8-census.txt new file mode 100644 index 00000000..aee52e49 --- /dev/null +++ b/docs/re/data/kf-unknown-4-8-census.txt @@ -0,0 +1,40 @@ +2859 builds, 90347 keyframes (parents + leaves) + ++4: 12 distinct values, 4289 non-zero keyframes (4.7473%) + 180 x4200 + -180 x24 + 22 x24 + 90 x18 + -45 x8 + 60 x8 ++8: 11 distinct values, 4064 non-zero keyframes (4.4982%) + 180 x3288 + 90 x201 + -180 x156 + 178 x144 + 45 x99 + 23 x95 ++12 (rotation): 157 distinct values, 12520 non-zero keyframes (13.8577%) + 90 x1968 + -90 x1260 + 180 x608 + 120 x540 + -58 x426 + 53 x408 + +keyframes with a non-zero +4 or +8: 6345 + GP_BUNK.pak e1 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0 + GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e3 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0 + GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360 + GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0 + GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360 + GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0 + GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0 + GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0 diff --git a/docs/re/structures/ui-keyframe-unknown-4-8.md b/docs/re/structures/ui-keyframe-unknown-4-8.md new file mode 100644 index 00000000..3369a7a8 --- /dev/null +++ b/docs/re/structures/ui-keyframe-unknown-4-8.md @@ -0,0 +1,86 @@ +# ๐ŸŸก The keyframe block's `+4` and `+8` โ€” narrowed, not decoded + +**Status:** ๐ŸŸก the space is much narrower than "unexplained", and the reason it +cannot be closed here is specific and worth stating. โ” **Not decoded** โ€” nothing +below is confirmed against the oracle. + +`+12` is โœ… decoded as a screen-plane rotation in degrees +([`ui-keyframe-rotation.md`](ui-keyframe-rotation.md)). `+4` and `+8` sit +immediately before it, are carried rather than dropped, and one standing reading +is that the three together are rotations about three axes โ€” *"not tied to an +observed rotation"*. + +Census over **every UI pak on the disc**: 2 859 builds, **90 347 keyframes**, +parents and nested leaves alike. +[`data/kf-unknown-4-8-census.txt`](../data/kf-unknown-4-8-census.txt), +`--example kf_unknown_census`. + +## They do not behave like `+12` + +| | `+4` | `+8` | `+12` (decoded rotation) | +|---|---|---|---| +| distinct values | **12** | **11** | **157** | +| non-zero keyframes | 4 289 (4.75 %) | 4 064 (4.50 %) | 12 520 (13.86 %) | +| commonest non-zero | **180** ร—4 200 | **180** ร—3 288 | 90 ร—1 968 | + +And per sprite-instance, across 14 241 of them โ€” with `+12` as the **control**, +since it is a field known to hold a real angle: + +| | both 0 and ยฑ180 in one build | ยฑ180 with no 0 | **more than 2 distinct values** | +|---|---|---|---| +| `+4` | 415 | 252 | **7** | +| `+8` | 360 | 180 | **99** | +| `+12` (control) | 155 | 30 | **396** | + +๐Ÿ”ด **`+4` takes more than two values on 7 instances out of 14 241; `+12` does so +on 396 โ€” 57ร— more.** In practice `+4` is a two-state field, and the state is +`180`. For a screen-plane sprite a 180ยฐ rotation about an in-plane axis **is a +mirror**, so the overwhelmingly common use of these fields is a **flip**. + +## โš ๏ธ But they are NOT booleans, and one element shows why + +`GP_TITLE` entry 7 has the only interesting case on the disc's title side: + +``` +ptlogo3a.t32 +4=-72 +12=-14 + +4=-18 +12=-4 + +4=-4 +12=-1 + +4=-1 +12=0 +``` + +**`+4` and `+12` decay to zero together**, `+4` running roughly 4โ€“5ร— `+12` at +each keyframe. That is a coupled two-axis settle, not a flag โ€” and it is the +strongest support the disc offers for the three-axis reading. The census's other +odd values (`22`, `60`, `โˆ’45`, `178`, `23`) say the same thing more weakly. + +โœ… **So the two readings reconcile:** the field **is** an angle, and its +overwhelmingly common *use* is the 180ยฐ special case that mirrors a sprite. A +consumer that treats it as a boolean will be right 97 % of the time and wrong on +`ptlogo3a`. + +## ๐Ÿ”ด Why it cannot be closed here โ€” the reach + +**All six non-zero `+4`/`+8` keyframes in `GP_TITLE` are in entry 7**, the +Japanese title: + +``` +e7 ptlogo3a.t32 +4=-72/-18/-4/-1 +e7 ptlogo_eff2.rat->ptlogo_eff2.t32 +4=180 (both keyframes) +``` + +* `title_jp` has **no oracle capture**, so a mirror or a two-axis settle cannot + be confirmed against the running game in this container; +* MISSION ยง7 scopes out *"localisation beyond English"*, so entry 7 is **not a + question this port has to answer**; +* the five English screens that *do* have captures have `+4 = +8 = 0` on every + keyframe โ€” **they never exercise these fields at all**. + +โš ๏ธ So this is not "needs more work"; it is **untestable against every oracle this +project holds**, and the only assets that would test it are out of scope. The +paks that use the fields heavily โ€” `GP_READY_ROOM` (4 686), `GP_DIALOG` (1 058) โ€” +are also outside the menu port's scope, and `GP_READY_ROOM` is separately a +recorded no-go. + +**For the port:** the five menu screens are unaffected either way. Carrying the +fields rather than dropping them, which `sylpheed-formats` already does, remains +the right handling.