From 0d2bf1f241bdbe669999688f67ee78cffa1a11cc Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:27:41 +0000 Subject: [PATCH] export: reach the splash by authored entry index, and key names by entry There were TWO splash screens and this port had neither. Entries 11/14 are the developer logos; entries 10/13 are the SQUARE ENIX publisher wordmark, the first thing the boot shows, which nothing in this project had noticed. They have no .rat layout child so `is_build` cannot see them, and the RE agent established that no CONTENT rule can either: design size fails (every extra composable bundle sampled is 1280x720, same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD, the splash halves are 3 and 7 -- the ranges overlap). So `screen_builds` is `is_build` plus an authored allow-list of ENTRY INDICES, each carrying a `why` that says it is a locator and not a claim. An allow-list rather than a loosened predicate because this is safe in GP_TITLE and would not be in general: there, widening adds exactly four bundles and all four are real screens, zero fragments. screen_names.json is rekeyed from enumeration ordinal to pak entry. Widening renumbers ordinals, and a name that moves when the enumeration rule changes is not a name -- the file always called the entry the stronger locator, and it is now the only stable one. The two unnamed plates therefore renamed build_10/11 -> build_12/15; they were always locators, and now they locate the right thing. All 16 export and validate. --- authored/flow.json | 53 +++++++++++++++++++ authored/screen_names.json | 77 +++++++++++++++++++++++----- authored/timing.json | 40 +++++++++++---- crates/sylpheed-export/src/main.rs | 82 +++++++++++++++++++++++++----- 4 files changed, 214 insertions(+), 38 deletions(-) create mode 100644 authored/flow.json diff --git a/authored/flow.json b/authored/flow.json new file mode 100644 index 00000000..3575f4ea --- /dev/null +++ b/authored/flow.json @@ -0,0 +1,53 @@ +{ + "format": "sylpheed.flow/1", + + "_": [ + "The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a", + "negative -- the order is in none of the four places it could have been. It is", + "not in config.ini's empty [SYSTEM], not in the movie manifest (which carries", + "assets, not transitions), not in a persistent GamePart field (the requested id", + "lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero", + "xrefs of any kind. A transition is a call with a name argument, chosen by code.", + "", + "So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE", + "agent watched the game do, not what any file on the disc says it does. Nothing", + "here may be presented as decoded." + ], + + "boot": [ + { + "screen": "publisher_logo", + "why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both." + }, + { + "screen": "developer_logos", + "why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2." + }, + { + "screen": "title", + "why": "HANDOFF Q2/Q6: the boot reaches the title after the splashes. The intro video (ADVERTISE_MOVIE -> ADV.wmv) plays between the splash and the title in the real boot and is SKIPPED here -- it is P4, and the sequencer names the gap rather than pretending the order is different." + } + ], + + "dwell": { + "_": [ + "DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer", + "logos at t=190 (3.17 s), both read from the disc. Holding beyond that would", + "be a number nobody has measured, so the sequencer holds for zero extra time", + "and the pacing is the disc's own.", + "", + "When a capture times the real boot, the extra hold per screen goes here." + ] + }, + + "screens": { + "_": [ + "What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", + "destination screens and the RE agent later decoded that a transition is a", + "lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,", + "LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`", + "targets when this is written, marked as the name match they are." + ] + } +} diff --git a/authored/screen_names.json b/authored/screen_names.json index 8ce84420..89df17c8 100644 --- a/authored/screen_names.json +++ b/authored/screen_names.json @@ -1,17 +1,18 @@ { "format": "sylpheed.screen_names/1", "_": [ - "Which GP_TITLE build is which screen. AUTHORED: the disc does not name its", + "Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its", "builds, so every name here is a decision. The identifications come from", - "HANDOFF Q2 (ui-title-build-map.md), which measured four of them against", - "framebuffer captures of the running game; the exporter stamps the name into", - "the screen file with name_source: \"authored\" so a reader can tell a", - "recovered name from an invented one.", + "HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer", + "captures of the running game; the exporter stamps the name into the screen", + "file with name_source: \"authored\" so a reader can tell a recovered name from", + "an invented one.", "", - "`build` is the index into the pak's list of screen builds -- what", - "`sylpheed-cli screen --build N` takes -- and is stable as long as the", - "enumeration rule is. The screen file also records the pak entry index,", - "which is the stronger locator.", + "KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the", + "ordinal; widening the enumeration to reach the splash renumbers ordinals, and", + "a name that moves when the enumeration rule changes is not a name. The entry", + "was always described here as the stronger locator -- now it is the only", + "stable one.", "", "Delete an entry here the day the RE agent decodes a name field." ], @@ -21,7 +22,10 @@ "name": "press_start", "why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture." }, - "3": { "name": "press_start_jp", "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." }, + "3": { + "name": "press_start_jp", + "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." + }, "4": { "name": "title", "why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture." @@ -34,12 +38,57 @@ "name": "extras", "why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture." }, - "7": { "name": "title_jp", "why": "HANDOFF Q2: the Japanese twin of build 4." }, - "8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." }, - "9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." } + "7": { + "name": "title_jp", + "why": "HANDOFF Q2: the Japanese twin of build 4." + }, + "8": { + "name": "main_menu_jp", + "why": "HANDOFF Q2: the Japanese twin of build 5." + }, + "9": { + "name": "extras_jp", + "why": "HANDOFF Q2: the Japanese twin of build 6." + }, + "10": { + "name": "publisher_logo", + "why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)." + }, + "13": { + "name": "publisher_logo_r", + "why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs." + }, + "11": { + "name": "developer_logos", + "why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements." + }, + "14": { + "name": "developer_logos_r", + "why": "The region twin of entry 11, as 13 is to 10." + } } }, "unnamed": { - "dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their build index rather than a name we would be inventing." + "dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing." + }, + "also_export": { + "dat/GP_TITLE.pak": { + "10": { + "name": "publisher_logo", + "why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments." + }, + "11": { + "name": "developer_logos", + "why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn." + }, + "13": { + "name": "publisher_logo_r", + "why": "As entry 10, region twin." + }, + "14": { + "name": "developer_logos_r", + "why": "As entry 11, region twin." + } + } } } diff --git a/authored/timing.json b/authored/timing.json index 52bc67ae..2ae18c5c 100644 --- a/authored/timing.json +++ b/authored/timing.json @@ -1,6 +1,5 @@ { "format": "sylpheed.timing/1", - "keyframe_units_per_second": 60, "why": [ "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", @@ -20,20 +19,41 @@ ], "kind": "measured", "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", - "ramp": "linear", "ramp_why": [ "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", "linear value at every one of the seven sampled frames, so there is no ease." ], - - "exit_ramp_seconds": null, + "exit_ramp_seconds": 0.4, "exit_ramp_why": [ - "NOT SET, deliberately. The last keyframe of every group carries no time --", - "the disc has no time slot there -- so the duration of the ramp INTO the", - "exit pose is unknown. HANDOFF Q7 measured the screen fade-out at ~0.4 s,", - "but that is the transition, which is P3's to author with its own evidence.", - "P2 plays the timed keyframes and holds; it never plays the exit ramp,", - "because it would have to invent how long it takes." + "HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.", + "", + "Every element of a screen ends on exactly ONE untimed keyframe, so there is", + "exactly one unknown duration per screen -- the ramp INTO that final keyframe.", + "This is that duration. ~0.4 s, which is 24 units at 60 units/s.", + "", + "The alternative readings were tested and refuted. It is not a black quad laid", + "over a frozen screen: under that model a black rect scales every region by the", + "same 1-alpha, so the button-region / background-region brightness RATIO would", + "be constant through the fade. Measured on the RE agent's filmstrip it falls", + "6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen", + "itself plays out: pteff00.prm ramps to opaque black while the button labels,", + "ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.", + "", + "REACH, quoted from the RE agent rather than smoothed over: the filmstrip is", + "downsampled and the button region contains some background, so this pins the", + "DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the", + "number as approximate and the model as established." + ], + "exit_ramp_units": 24, + "dwell_seconds": null, + "dwell_why": [ + "NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos", + "at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would", + "be inventing a number nobody measured, so the sequencer holds for zero extra", + "time and the pacing you see is the disc's own.", + "", + "If a capture ever times the real boot, this is where that number goes." ] } diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index b1810836..5e5778bd 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -78,7 +78,11 @@ struct Manifest { warnings: Vec, } -/// The authored `build index → name` map, keyed by archive path. +/// The authored `pak entry index → name` map, keyed by archive path. +/// +/// Keyed by **entry**, not by the enumeration ordinal. The file itself always +/// called the entry "the stronger locator"; it is now also the only stable one, +/// because widening the enumeration to reach the splash renumbers the ordinals. type NameMap = std::collections::BTreeMap>; #[derive(serde::Deserialize)] @@ -96,6 +100,8 @@ fn load_names(authored: &Path) -> Result { #[derive(serde::Deserialize)] struct File { archives: NameMap, + #[serde(default)] + also_export: AlsoExport, } let raw = std::fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; @@ -104,17 +110,54 @@ fn load_names(authored: &Path) -> Result { .archives) } -/// Every RATC entry of a UI pak that parses as a screen build. +/// Extra pak entries to export that `is_build` does not accept, keyed by +/// archive. AUTHORED, and each carries its own `why`. +type AlsoExport = + std::collections::BTreeMap>; + +fn load_also_export(authored: &Path) -> Result { + let path = authored.join("screen_names.json"); + if !path.exists() { + return Ok(AlsoExport::new()); + } + #[derive(serde::Deserialize)] + struct File { + #[serde(default)] + also_export: AlsoExport, + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + Ok(serde_json::from_str::(&raw) + .with_context(|| format!("parse {}", path.display()))? + .also_export) +} + +/// Every RATC entry of a UI pak this exporter treats as a screen. /// -/// The filter is `is_build` — a bundle with a `.rat` layout child. The developer -/// splash declares its sprites directly and has none, so it is invisible here; -/// that is P3's problem and is recorded as a manifest warning rather than -/// silently widened. -fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec)> { +/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an +/// authored allow-list of entry indices**. +/// +/// The allow-list exists because the splash screens declare their sprites +/// directly and have no `.rat` child, so `is_build` cannot see them, and **there +/// is no content rule that would**. The RE agent looked: design size fails +/// (every extra composable bundle sampled is 1280x720, the same as every +/// screen) and element count fails (fragments run 2..15 elements in +/// `GP_OPTIONS`/`GP_SAVE_LOAD` while the splash halves are 3 and 7 — the ranges +/// overlap). So the splashes are located **by entry index**, which is a locator +/// and not a claim, and each one says so in its own `why`. +/// +/// This is safe here rather than in general: in `GP_TITLE` the widened set adds +/// exactly four bundles and all four are real screens, with zero fragments. In +/// another archive it would not be, which is why this is an allow-list and not +/// a widened predicate. +fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap>) + -> Vec<(usize, Vec)> +{ let mut out = Vec::new(); for (i, e) in ar.entries().iter().enumerate() { let Ok(bytes) = ar.read(e) else { continue }; - if ui_layout::is_build(&bytes) { + let allowed = also.is_some_and(|m| m.contains_key(&i.to_string())); + if ui_layout::is_build(&bytes) || allowed { out.push((i, bytes)); } } @@ -150,18 +193,26 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { let archive = "dat/GP_TITLE.pak"; let pak = disc.join(archive); let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?; - let builds = screen_builds(&ar); + let also = load_also_export(authored_dir)?; + let archive_also = also.get(archive); + let builds = screen_builds(&ar, archive_also); println!("{archive}: {} screen build(s)", builds.len()); let archive_names = names.get(archive); let mut screens = Vec::new(); for (build_idx, (entry, bytes)) in builds.iter().enumerate() { - let authored = archive_names.and_then(|m| m.get(&build_idx.to_string())); - let (name, name_source, why) = match authored { + // Keyed by ENTRY, not by the ordinal: widening the enumeration to reach + // the splash renumbers ordinals, and a name that moves when the rule + // changes is not a name. + let key = entry.to_string(); + let named = archive_names + .and_then(|m| m.get(&key)) + .or_else(|| archive_also.and_then(|m| m.get(&key))); + let (name, name_source, why) = match named { Some(e) => (e.name.clone(), "authored", e.why.clone()), // Nobody has identified this build. Emit a stable synthetic id and // say in the file that the name is not a recovered one. - None => (format!("build_{build_idx:02}"), "index", None), + None => (format!("build_{entry:02}"), "index", None), }; let ex = screen::export_build( &out, @@ -204,8 +255,11 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { warnings: vec![ "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." .into(), - "The developer-logo splash is not here: it declares its sprites directly and has \ - no .rat layout child, so `is_build` does not see it. P3." + "The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \ + layout child, so `is_build` cannot see them and no content rule can: element \ + count and design size both overlap with two-element fragments in other archives. \ + They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \ + which is a locator and not a claim -- see each one's name_why." .into(), ], };