recover: the OPTIONS menu work from the deleted auto/port-p6-audio
The nine files touched by the OPTIONS commits of 2026-09-03 (77f1d18,fda417a,3efe1cc,80042cb,4c24e06,a921c1e,41f1331,6b4b1df,edf8979), taken as of0148cb8, the tip of auto/port-p6-audio. The branch was deleted from the server on 2026-09-17 during the consolidation cleanup; issue #6 asks for this work as a reviewable PR, so it is recovered here before the commits are garbage collected. This is a review slice, not a self-consistent tree: the OPTIONS work and the F5/F6 work interleaved in the original history and cannot be separated by file, so each file carries whatever else had changed in it by 2026-09-04, and files it depends on are absent. The complete state is recover/port-f5-f6. Refs #6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod audio;
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
@@ -76,6 +77,48 @@ struct ManifestVideo {
|
||||
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||
command: String,
|
||||
why: &'static str,
|
||||
/// What the runtime should have played, so it can report what it did.
|
||||
/// See `video::Transcoded::duration_s` — the port measured its player
|
||||
/// presenting 28–47 % of a stream's frames, and seconds alone hide that.
|
||||
duration_s: f64,
|
||||
fps: f64,
|
||||
}
|
||||
|
||||
/// One exported audio file. Carries the same provenance a video does, plus the
|
||||
/// measured peak and duration: silence and clipping are the two audio failures
|
||||
/// that pass every check that is not looking for them.
|
||||
#[derive(Serialize)]
|
||||
struct ManifestAudio {
|
||||
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
|
||||
/// than a prefix on `name` that a consumer would have to parse.
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
file: String,
|
||||
command: String,
|
||||
why: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
peak_dbfs: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_s: Option<f32>,
|
||||
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
|
||||
/// runtime to announce. Absent means nothing is known to be missing --
|
||||
/// never that the asset was checked and is complete.
|
||||
///
|
||||
/// It exists because the export could already say this and the RUNTIME
|
||||
/// could not. `why` carries the full account, but it is a paragraph aimed
|
||||
/// at a reader of the manifest; a player hears clean dialogue and has no
|
||||
/// way to learn that a stream is absent from it. This port already
|
||||
/// announces the two measured screens NEW GAME jumps over, on the principle
|
||||
/// that a gap is announced before it is opened. Audio had no equivalent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
incomplete: Option<String>,
|
||||
/// The game's own cue identifier where one is a NAME MATCH. Absent means
|
||||
/// nobody has claimed one -- never that the binding is unknown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name_match: Option<String>,
|
||||
/// What the runtime does at the end of the file, where that was authored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
loop_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -88,6 +131,8 @@ struct Manifest {
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
audio: Vec<ManifestAudio>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -163,6 +208,54 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
|
||||
/// 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.
|
||||
/// Which archives the export reads, from `authored/screen_names.json`
|
||||
/// `export_archives`.
|
||||
///
|
||||
/// 🔴 THIS WAS ONE HARDCODED CONSTANT AND IT COST FOUR MENU DESTINATIONS.
|
||||
/// `authored/flow.json` records LOAD GAME, TUTORIAL, OPTIONS and NEW GAME's
|
||||
/// difficulty chain as MEASURED destinations that are `blocked` because "not a
|
||||
/// GP_TITLE build, so there is no screen file to go to". The blocker was never
|
||||
/// the disc or the reader -- `examples/probe_archives.rs` finds screen builds in
|
||||
/// 24 archives using the EXISTING detector. It was this line.
|
||||
///
|
||||
/// Absent from the authored file, it stays exactly what it was, so an old
|
||||
/// `authored/` tree exports what it always did.
|
||||
fn load_export_archives(authored: &Path) -> Result<Vec<String>> {
|
||||
let path = authored.join("screen_names.json");
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return Ok(vec!["dat/GP_TITLE.pak".into()]);
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
match v.get("export_archives").and_then(|a| a.as_array()) {
|
||||
None => Ok(vec!["dat/GP_TITLE.pak".into()]),
|
||||
Some(list) => Ok(list
|
||||
.iter()
|
||||
.filter_map(|e| e.as_str().map(str::to_owned))
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sprite subdirectory for an archive: `dat/GP_OPTIONS.pak` -> `options`.
|
||||
///
|
||||
/// ⚠️ NOT cosmetic. Sprites are written to `sprites/<group>/<screen>/`, so two
|
||||
/// archives sharing a group would collide by screen name -- and unnamed builds
|
||||
/// are named `build_NN` by ENTRY INDEX, which restarts at 0 in every archive.
|
||||
/// `GP_TITLE` keeps its historical `title` so no existing path moves.
|
||||
fn group_for(archive: &str) -> &'static str {
|
||||
match archive {
|
||||
"dat/GP_TITLE.pak" => "title",
|
||||
"dat/GP_OPTIONS.pak" => "options",
|
||||
"dat/GP_SAVE_LOAD.pak" => "save_load",
|
||||
"dat/GP_TUTORIAL.pak" => "tutorial",
|
||||
"dat/GP_DIALOG.pak" => "dialog",
|
||||
// Deliberately not derived from the filename: a new archive should be a
|
||||
// decision someone made, not a directory that appears because a string
|
||||
// parsed. An unmapped archive is rejected below.
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
|
||||
-> Vec<(usize, Vec<u8>)>
|
||||
{
|
||||
@@ -195,86 +288,275 @@ fn main() -> Result<()> {
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
|
||||
// has to know about; it is not an error, and it is not a log line, because
|
||||
// the person who needs it reads `manifest.json` and never sees stdout.
|
||||
let mut warnings: Vec<String> = vec![
|
||||
String::new(), // replaced below once the archive list is known
|
||||
"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(),
|
||||
];
|
||||
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
//
|
||||
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
|
||||
// `video::transcode` has always carried a cache -- it writes a `.cmd`
|
||||
// sidecar with the exact command, the source size and the channel count, and
|
||||
// skips the encode when all three still match. Its own doc comment says
|
||||
// "without it every re-export pays ~4 minutes to produce a byte-identical
|
||||
// file". **This wipe deleted the sidecar and the output immediately before
|
||||
// the check, so the cache had never hit once.** Six exports in one session
|
||||
// paid ~48 minutes of Theora to produce five byte-identical files, and
|
||||
// nothing reported it: the cache is silent when it works and silent when it
|
||||
// does not.
|
||||
//
|
||||
// The wholesale guarantee is kept rather than weakened -- everything else is
|
||||
// still cleared outright, and `prune_videos` below deletes any file in
|
||||
// `video/` that this run did not claim, so a movie that stops being exported
|
||||
// still stops existing.
|
||||
if out.exists() {
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "video" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
std::fs::remove_dir_all(entry.path())
|
||||
} else {
|
||||
std::fs::remove_file(entry.path())
|
||||
}
|
||||
.with_context(|| format!("clear {}", entry.path().display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
let archive = "dat/GP_TITLE.pak";
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
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 archives = load_export_archives(authored_dir)?;
|
||||
warnings[0] = format!(
|
||||
"Screen builds from {} only ({}). Other archives on the disc also contain UI \
|
||||
builds and are not exported. Only the two movies MISSION section 6 puts in scope.",
|
||||
archives.len(),
|
||||
archives.join(", ")
|
||||
);
|
||||
let mut screens = Vec::new();
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
// 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_{entry:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
"title",
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
for archive in archives.iter().map(String::as_str) {
|
||||
let group = group_for(archive);
|
||||
if group.is_empty() {
|
||||
anyhow::bail!(
|
||||
"authored/screen_names.json export_archives lists {archive}, which has no \
|
||||
sprite group in group_for(). Add one deliberately -- deriving it from the \
|
||||
filename would let a typo create a directory."
|
||||
);
|
||||
}
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
let archive_also = also.get(archive);
|
||||
let builds = screen_builds(&ar, archive_also);
|
||||
println!("{archive}: {} screen build(s) -> sprites/{group}/", builds.len());
|
||||
|
||||
let archive_names = names.get(archive);
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
// 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_{entry:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
group,
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
|
||||
// 🔴 The export deviates from a HUMAN decision, and until this warning
|
||||
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
|
||||
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
|
||||
// justified for one of the two movies and over-broad for the other, and
|
||||
// which of the three options to take is not the exporter's call -- so it is
|
||||
// reported on every run rather than left in a doc comment nobody opens.
|
||||
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
|
||||
warnings.push(
|
||||
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
command: t.command,
|
||||
why: t.why,
|
||||
duration_s: t.duration_s,
|
||||
fps: t.fps,
|
||||
});
|
||||
}
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
prune_videos(out, &videos)?;
|
||||
|
||||
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
|
||||
// because they were measured off the running game and are on the disc in no
|
||||
// findable form, the BGM choice because HANDOFF Q10 is a negative and
|
||||
// nothing states which track a menu plays. See `authored/audio.json`.
|
||||
let mut audio = Vec::new();
|
||||
let audio_cfg = audio::load(authored_dir)?;
|
||||
match &audio_cfg {
|
||||
None => println!(" no authored/audio.json -- no audio exported"),
|
||||
Some(cfg) => {
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for a in audio::export_cues(&source, out, &cfg.se)? {
|
||||
println!(
|
||||
" se {:<8} -> {} ({})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a)
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
for (role, spec) in &cfg.bgm {
|
||||
match audio::export_bgm(&source, out, role, spec)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
spec.bank,
|
||||
a.sub_waves
|
||||
);
|
||||
// HANDOFF Q10's census is "exactly two waves of
|
||||
// identical duration, 32/32 banks on the disc". When
|
||||
// `media` hands back a different number, SAY SO -- the
|
||||
// port does not get to decide that one of them is not a
|
||||
// stem, and silently summing an extra region into the
|
||||
// music is precisely the media-assembly mistake MISSION
|
||||
// section 2 names. The decoder's answer is what ships;
|
||||
// the disagreement is what gets reported.
|
||||
if a.sub_waves != 2 {
|
||||
warnings.push(format!(
|
||||
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
|
||||
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
|
||||
says a music bank is EXACTLY TWO waves of identical duration \
|
||||
(32/32 banks). All {} are summed, because choosing which to drop \
|
||||
is a decoding question and this exporter does not answer those. \
|
||||
See docs/port/BLOCKED.md.",
|
||||
a.sub_waves, spec.bank, a.sub_waves
|
||||
));
|
||||
}
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
// Not an error: the authored bank may simply not be on this
|
||||
// disc, and the export of everything else is still good.
|
||||
None => warnings.push(format!(
|
||||
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
|
||||
this disc's sound.pak -- no BGM exported for that role.",
|
||||
spec.bank
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cutscene voices are DERIVED, not authored, so this runs outside the
|
||||
// `authored/audio.json` block above: the binding comes off the disc (the
|
||||
// movie manifest in `tables.pak`), and an export with no authored audio
|
||||
// should still carry the dialogue for the movies it ships.
|
||||
//
|
||||
// A movie that resolves to no region is genuinely unvoiced and gets a
|
||||
// warning rather than a substitute -- for both movies in scope this port
|
||||
// expects a region, so a warning here is a real signal and not noise.
|
||||
{
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for (stem, len) in &movie_lengths {
|
||||
// The presentation choice is AUTHORED and this block runs even when
|
||||
// there is no `authored/audio.json` -- the voice binding is decoded,
|
||||
// so the dialogue exports either way and only the choice defaults.
|
||||
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
|
||||
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
|
||||
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
|
||||
Some(a) => {
|
||||
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
|
||||
// export is known to be missing audio the game plays, and
|
||||
// the failure sounds like success: one stream decodes to
|
||||
// clean dialogue, so nobody listening finds out.
|
||||
if a.kept_waves < a.content_waves {
|
||||
warnings.push(format!(
|
||||
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
|
||||
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
|
||||
three XMA contexts, byte sizes matching the disc payloads exactly). \
|
||||
The export carries ONE. Nothing in the audio reveals this -- a \
|
||||
single stream is clean audible dialogue. Held rather than summed \
|
||||
because an equal-gain sum of channel pairs is not a downmix and \
|
||||
would be a second guess, not a fix. See authored/audio.json voice \
|
||||
and docs/port/BLOCKED.md.",
|
||||
a.file, a.sub_waves
|
||||
));
|
||||
}
|
||||
println!(
|
||||
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
a.kept_waves,
|
||||
a.sub_waves,
|
||||
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
None => warnings.push(format!(
|
||||
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
@@ -283,16 +565,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"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(),
|
||||
],
|
||||
audio,
|
||||
warnings,
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
@@ -301,3 +575,81 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
impl From<audio::Exported> for ManifestAudio {
|
||||
fn from(a: audio::Exported) -> Self {
|
||||
ManifestAudio {
|
||||
kind: a.kind,
|
||||
name: a.name,
|
||||
file: a.file,
|
||||
command: a.command,
|
||||
why: a.why,
|
||||
peak_dbfs: a.peak_dbfs,
|
||||
duration_s: a.duration_s,
|
||||
incomplete: (a.kept_waves < a.content_waves).then(|| {
|
||||
format!(
|
||||
"{} of {} streams. The running game decodes all {} concurrently. \
|
||||
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
|
||||
WHICH streams are dropped and why differs per asset; the manifest \
|
||||
entry's `why` says, and it is not the same story twice.",
|
||||
a.kept_waves, a.sub_waves, a.sub_waves
|
||||
)
|
||||
}),
|
||||
name_match: a.name_match,
|
||||
loop_mode: a.loop_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two numbers worth reading on an audio line, in the console.
|
||||
///
|
||||
/// Printed rather than left to the manifest because the failure this catches is
|
||||
/// a SILENT file: the right duration, the right channel count, the right size,
|
||||
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
|
||||
/// person notices without being told to look.
|
||||
fn describe(a: &audio::Exported) -> String {
|
||||
let peak = match a.peak_dbfs {
|
||||
Some(p) => format!("peak {p:.1} dBFS"),
|
||||
None => "peak unmeasured".into(),
|
||||
};
|
||||
match a.duration_s {
|
||||
Some(d) => format!("{d:.3} s, {peak}"),
|
||||
None => peak,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Delete anything in `video/` this run did not produce.
|
||||
///
|
||||
/// `video/` is the one directory the wholesale wipe spares, so that the
|
||||
/// transcode cache survives to be consulted. This restores the guarantee the
|
||||
/// wipe exists for: a movie that stops being exported stops existing, rather
|
||||
/// than lingering as a file the manifest no longer lists.
|
||||
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
|
||||
let dir = out.join("video");
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for v in kept {
|
||||
if let Some(name) = Path::new(&v.file).file_name() {
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
keep.push(name.clone());
|
||||
// The cache sidecar goes with the file it stamps.
|
||||
if let Some(stem) = Path::new(&name).file_stem() {
|
||||
keep.push(format!("{}.cmd", stem.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if keep.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
println!(" video {name} is no longer exported -- removed");
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,13 +17,27 @@ use sylpheed_formats::{t8ad, ui_layout};
|
||||
///
|
||||
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
|
||||
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
|
||||
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
|
||||
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
|
||||
/// has zero `0x3002`. The mapping is decoded for the kinds listed; anything else
|
||||
/// exports as `unknown` with its raw kind visible.
|
||||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||||
// 🔴 BIT 0 IS THE PARENT FLAG AND CARRIES NO ROLE INFORMATION. Decoded
|
||||
// disc-wide: `kind & 1` agrees with "has a parent" on 15 493 elements with
|
||||
// zero disagreements (`docs/re/ui-kind-bit0-is-has-parent.md`). So a role
|
||||
// table keyed on the raw kind splits every class in two and calls the
|
||||
// parented half `unknown` -- which is how the OPTIONS menu's rows came out
|
||||
// roleless while the exporter had already accepted them as buttons.
|
||||
//
|
||||
// ⚠️ APPLIED TO EVERY PAIR, NOT JUST THE ONE THAT FAILED. Fixing only
|
||||
// `0x3003` would have left `0x1` as `unknown` while `0x0` is `decoration`,
|
||||
// i.e. the same inconsistency one kind along -- and half-applying this
|
||||
// decode is exactly what produced the failure this is fixing.
|
||||
//
|
||||
// ⚠️ `0x73002`/`0x73003` are NOT folded in. Their `0x70000` bits are
|
||||
// undecoded, so they stay `unknown` with their raw kind visible.
|
||||
match kind {
|
||||
0x3002 => "button",
|
||||
0x10 if !has_sprite => "primitive",
|
||||
0x0 => "decoration",
|
||||
0x3002 | 0x3003 => "button",
|
||||
0x10 | 0x11 if !has_sprite => "primitive",
|
||||
0x0 | 0x1 => "decoration",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -103,6 +117,14 @@ pub struct FocusElement {
|
||||
pub id: String,
|
||||
pub declared: String,
|
||||
pub sprite: Option<String>,
|
||||
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
|
||||
///
|
||||
/// A leaf's sprite may live in the leaf's own table or in the parent
|
||||
/// bundle's, so the bit is looked up in the same two places, in the same
|
||||
/// order, that the PNG is written from.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub pivot: [u32; 2],
|
||||
pub rest: Rest,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
@@ -112,6 +134,34 @@ pub struct FocusElement {
|
||||
pub struct Focus {
|
||||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||||
pub record: String,
|
||||
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
|
||||
/// units — which is not the same thing as the last keyframe's time.
|
||||
///
|
||||
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
|
||||
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
|
||||
/// the period from the largest keyframe time — what the port did until now —
|
||||
/// runs it 14 % fast and deletes the dark rest entirely.
|
||||
///
|
||||
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
|
||||
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
|
||||
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
|
||||
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
|
||||
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
|
||||
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
|
||||
/// bare until 2026-09-01, which is a population-scoped statistic reported
|
||||
/// without its population):
|
||||
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
|
||||
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
|
||||
/// their own last pose**; on the eight records this port animates, seven
|
||||
/// exact and `ptbtn00f` the one hold.
|
||||
///
|
||||
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
|
||||
/// held its own guard and byte read, because the field was decoded in an
|
||||
/// example and a test and exposed in no public API on any ref. It is now
|
||||
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
|
||||
/// the local copy is deleted — the doc comment that promised that deletion
|
||||
/// is the only reason it did not quietly become permanent.
|
||||
pub loop_length_units: Option<u32>,
|
||||
/// Back-to-front, in the leaf's own declaration order.
|
||||
pub elements: Vec<FocusElement>,
|
||||
}
|
||||
@@ -141,6 +191,59 @@ pub struct Element {
|
||||
/// convention and a consumer may still want the bare highlight texture.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<Focus>,
|
||||
|
||||
/// This element's own `.rat` leaf, when its declared name is itself a
|
||||
/// record in the bundle.
|
||||
///
|
||||
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
|
||||
/// the title declare scale 100 % and rotation 0 at the parent, and their
|
||||
/// leaves declare **(100, 600) at +30°** and **(100, 800) at −45°** — and
|
||||
/// the leaves *move*, x from −639 → 1521 and 1721 → −839. `ui_layout`'s own
|
||||
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
|
||||
/// records, which the census never opened."* Neither did this exporter: it
|
||||
/// opened a leaf only for a FOCUS record, via `highlight_name`.
|
||||
///
|
||||
/// That omission is measurable. It is the whole of the title's 1.82 %
|
||||
/// disagreement with the oracle — the port draws two 400 px sprites upright
|
||||
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
|
||||
/// quads across the frame at opposite leans.
|
||||
///
|
||||
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
|
||||
/// ramp on a different span — parent 0→255 over t=70…238, leaf
|
||||
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
|
||||
/// question and not the port's to answer. The data is exported so it stops
|
||||
/// being invisible; `ScreenView` ignores it until the composition rule is
|
||||
/// known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf: Option<Focus>,
|
||||
|
||||
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
|
||||
/// what the game draws.
|
||||
///
|
||||
/// Decided here rather than in the runtime because it is disc knowledge.
|
||||
/// The Decoder's rule: *"the discriminator is which record carries the
|
||||
/// geometry, not a fixed order"* — and the census over this export splits
|
||||
/// cleanly, with no ambiguous middle:
|
||||
///
|
||||
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
|
||||
/// exactly. That is the BASE-record case `screen.rs` already handled: the
|
||||
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
|
||||
/// y=402) and the parent wins. Flag is false; nothing changes.
|
||||
/// * **16 differ**, and all of them differ in scale or rotation, not by a
|
||||
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
|
||||
/// and (100,800) at −45° against an identity parent), two
|
||||
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
|
||||
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
|
||||
///
|
||||
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
|
||||
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
|
||||
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
|
||||
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
|
||||
/// measured. The other two are the same shape and are **not** separately
|
||||
/// confirmed; they are flagged so the harness can adjudicate them rather
|
||||
/// than being asserted.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub leaf_carries_geometry: bool,
|
||||
/// The raw `opt ` link inside this element's `.rat` record.
|
||||
///
|
||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||
@@ -161,6 +264,23 @@ pub struct Element {
|
||||
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
|
||||
/// `"implied"` = **measured off the running game**, for elements that carry
|
||||
/// no header. `"none"` = neither; sorts last.
|
||||
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`.
|
||||
///
|
||||
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
|
||||
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
|
||||
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
|
||||
/// A name-keyed map cannot answer for a screen nobody drove the game to,
|
||||
/// which is why the port was drawing the English menus additive and the
|
||||
/// Japanese ones alpha-over — asserting by omission that the JP build
|
||||
/// blends differently. The bit is on the disc for every screen at once.
|
||||
///
|
||||
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
|
||||
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
|
||||
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
|
||||
/// 0 of 14 additive elements set it and 9 non-additive ones do.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub layer_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
@@ -201,6 +321,35 @@ pub struct Screen {
|
||||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||||
/// and not to be trusted for anything else.
|
||||
pub buttons: Vec<String>,
|
||||
|
||||
/// The instant every element of this screen is settled at, and the width of
|
||||
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
|
||||
/// units, absent when the screen has fewer than two keyframe times.
|
||||
///
|
||||
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
|
||||
/// each element at its own `rest()` is right for anything that ends the
|
||||
/// screen settled and **exactly wrong for a transient**: the title's
|
||||
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0
|
||||
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
|
||||
/// it burning forever. There are five of these, and `rest()` draws all five
|
||||
/// at once, saturating the light arc.
|
||||
///
|
||||
/// The window is the **longest interval containing no keyframe time**, over
|
||||
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
|
||||
/// that exclusion is what reproduces the Decoder's independently computed
|
||||
/// `[160, 236]` for the title: including the `ptloop` leaves gives
|
||||
/// `[269, 540]` instead.
|
||||
///
|
||||
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
|
||||
/// export the widths split with nothing in between — `press_start` 214,
|
||||
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
|
||||
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
|
||||
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
|
||||
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
|
||||
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
|
||||
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settle_window: Option<[i64; 3]>,
|
||||
/// What this file does not answer. A consumer needing one of these must get
|
||||
/// it from `authored/`.
|
||||
pub unresolved: Vec<&'static str>,
|
||||
@@ -316,6 +465,76 @@ pub fn export_build(
|
||||
// Contrast with a BASE record, where the leaf duplicates the parent's
|
||||
// placement and the two can differ by a unit (ptbtn04: parent y=401,
|
||||
// leaf y=402). There the parent wins. Here there is no parent.
|
||||
// Reads one record in the bundle as a nested build and returns its
|
||||
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
|
||||
// element whose OWN declared name is a record (`ptloop01.rat`). One
|
||||
// implementation, because the second case was missing for eight
|
||||
// milestones and a second copy is how it would go missing again.
|
||||
let read_leaf = |rec: &str,
|
||||
written: &mut std::collections::BTreeMap<String, ()>,
|
||||
missing: &mut Vec<String>|
|
||||
-> Result<Option<Focus>> {
|
||||
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
|
||||
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut fes = Vec::new();
|
||||
for fe in &leaf.elements {
|
||||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||||
let mut fsprite = None;
|
||||
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|
||||
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
|
||||
{
|
||||
fsprite = Some(sprite_rel(sp));
|
||||
} else if sp.ends_with(".t32") {
|
||||
missing.push(sp.to_string());
|
||||
}
|
||||
let Some(r) = fe.rest() else { continue };
|
||||
fes.push(FocusElement {
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
scale: [r.scale_x, r.scale_y],
|
||||
tint_rgba: hex32(r.tint),
|
||||
fade_argb: hex32(r.fade),
|
||||
rotation_deg: r.rotation_deg,
|
||||
t: r.time,
|
||||
},
|
||||
keyframes: fe
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| Keyframe {
|
||||
t: k.time,
|
||||
pos: [k.x, k.y],
|
||||
scale: [k.scale_x, k.scale_y],
|
||||
tint_rgba: hex32(k.tint),
|
||||
fade_argb: hex32(k.fade),
|
||||
rotation_deg: k.rotation_deg,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(if fes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Focus {
|
||||
record: rec.to_string(),
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
// An element whose own declared name is a record in this bundle carries
|
||||
// its geometry THERE, not in its parent entry. See `Element::leaf`.
|
||||
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
|
||||
|
||||
let mut focus = None;
|
||||
if let Some(rec) = highlight_name(&el.name) {
|
||||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||||
@@ -341,6 +560,9 @@ pub fn export_build(
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
@@ -365,7 +587,11 @@ pub fn export_build(
|
||||
});
|
||||
}
|
||||
if !fes.is_empty() {
|
||||
focus = Some(Focus { record: rec, elements: fes });
|
||||
focus = Some(Focus {
|
||||
record: rec,
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,10 +623,21 @@ pub fn export_build(
|
||||
sprite: sprite_out,
|
||||
focus_sprite,
|
||||
focus,
|
||||
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
|
||||
let p = el.rest();
|
||||
l.elements.iter().any(|le| {
|
||||
p.is_none_or(|p| {
|
||||
le.rest.scale != [p.scale_x, p.scale_y]
|
||||
|| le.rest.rotation_deg != p.rotation_deg
|
||||
})
|
||||
})
|
||||
}),
|
||||
leaf,
|
||||
opt_link: el.focus_link.clone(),
|
||||
pivot: [el.pivot_x, el.pivot_y],
|
||||
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
||||
parent: el.parent,
|
||||
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
|
||||
layer_source,
|
||||
layer,
|
||||
focused: el.focused,
|
||||
@@ -419,14 +656,33 @@ pub fn export_build(
|
||||
|
||||
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
|
||||
// focused-state record is not itself a menu item.
|
||||
//
|
||||
// 🔴 `0x3003` IS `0x3002`. Bit 0 of `kind` is the PARENT FLAG and carries no
|
||||
// role information: decoded disc-wide over every `.pak` in `dat/`, `kind & 1`
|
||||
// agrees with "has a parent" on 15 493 elements with ZERO disagreements
|
||||
// (`docs/re/ui-kind-bit0-is-has-parent.md`). Matching only `0x3002` meant the
|
||||
// OPTIONS menu's five rows -- parented, hence `0x3003` -- were not buttons,
|
||||
// so the screen opened and could not be navigated.
|
||||
//
|
||||
// ⚠️ TWO VALUES, LISTED, NOT A MASK. `kind & 0xFFFE == 0x3002` would also
|
||||
// match `0x73002`/`0x73003` -- 160 elements whose `0x70000` bits nobody has
|
||||
// decoded -- and it would do it silently, on screens neither agent has
|
||||
// looked at. Those are excluded by construction until somebody decides about
|
||||
// them deliberately.
|
||||
let mut buttons: Vec<(i32, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.kind == 0x3002 && !e.focused)
|
||||
.filter(|e| matches!(e.kind, 0x3002 | 0x3003) && !e.focused)
|
||||
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let window = settle_window(&elements);
|
||||
let order = forced_backdrop_first(
|
||||
ui_layout::derived_paint_order(&b, bundle),
|
||||
&elements,
|
||||
[b.design_w, b.design_h],
|
||||
);
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/3",
|
||||
exporter: exporter.to_string(),
|
||||
@@ -441,8 +697,9 @@ pub fn export_build(
|
||||
name_why,
|
||||
design: [b.design_w, b.design_h],
|
||||
elements,
|
||||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||||
paint_order: order,
|
||||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||||
settle_window: window,
|
||||
unresolved: vec![
|
||||
// The time unit is measured off the running game, not on the disc.
|
||||
"keyframe_time_unit",
|
||||
@@ -473,3 +730,234 @@ pub fn export_build(
|
||||
missing,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
|
||||
///
|
||||
/// See [`Screen::settle_window`] for why this is the settled instant and why
|
||||
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
|
||||
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
|
||||
let mut times: Vec<i64> = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
|
||||
.collect();
|
||||
times.sort_unstable();
|
||||
times.dedup();
|
||||
if times.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
|
||||
//
|
||||
// The widest keyframe-free interval is only a settled state if the screen is
|
||||
// actually PRESENTING something across it. `press_start` is the case that
|
||||
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
|
||||
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
|
||||
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
|
||||
// and the runtime then answered every question about that screen at t=107.
|
||||
// The result was that the PRESS (A) plate could not be drawn at any instant
|
||||
// at all, including the boot's own end state, whose entire purpose is to
|
||||
// show it.
|
||||
//
|
||||
// The fix is not a tuned threshold: it is that the heuristic was reading an
|
||||
// interval where the screen is BLANK as the interval where it has arrived.
|
||||
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
|
||||
// under the runtime's 30-unit bar, so it falls back to each element's own
|
||||
// hold -- which is the plate, opaque, exactly as the disc declares it.
|
||||
//
|
||||
// ⚠️ This does not disturb the windows the settle instant was measured on.
|
||||
// `title` keeps [160, 236]: elements are visible across it, and the
|
||||
// Decoder's draw stream independently found the game's clock freezing in
|
||||
// that same interval.
|
||||
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
|
||||
let (a, b) = times
|
||||
.windows(2)
|
||||
.map(|w| (w[0], w[1]))
|
||||
.filter(|(a, b)| visible_at((a + b) / 2))
|
||||
.max_by_key(|(a, b)| b - a)?;
|
||||
Some([a, b, (a + b) / 2])
|
||||
}
|
||||
|
||||
|
||||
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
|
||||
fn alpha_at(e: &Element, t: i64) -> u8 {
|
||||
let ks = &e.keyframes;
|
||||
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
|
||||
.unwrap_or(0) >> 24) as i64;
|
||||
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return a(timed[0]) as u8;
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return a(w[0]) as u8;
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
|
||||
}
|
||||
}
|
||||
a(timed[timed.len() - 1]) as u8
|
||||
}
|
||||
|
||||
/// Scale of one element at instant `t`, in percent per axis, under the same
|
||||
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
|
||||
/// that animates passes through every value between its keyframes.
|
||||
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
|
||||
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return [100.0, 100.0];
|
||||
}
|
||||
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return [g(timed[0], 0), g(timed[0], 1)];
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return [g(w[0], 0), g(w[0], 1)];
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return [
|
||||
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
|
||||
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
|
||||
];
|
||||
}
|
||||
}
|
||||
let l = timed[timed.len() - 1];
|
||||
[g(l, 0), g(l, 1)]
|
||||
}
|
||||
|
||||
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
|
||||
/// file forces it there.
|
||||
///
|
||||
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
|
||||
/// *an element that covers the screen and is fully opaque at some instant cannot
|
||||
/// paint above anything visible at that instant; where the elements visible
|
||||
/// during its opaque span are ALL of them, its position is forced to first.*
|
||||
///
|
||||
/// It was found because `build_12`/`build_15` are **black at every instant** of
|
||||
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
|
||||
/// 39 instants while all 9 other elements live and die inside that span. A
|
||||
/// screen that is black for its whole life is impossible on its face, which is
|
||||
/// the only kind of check that survives two renderers sharing an assumption:
|
||||
/// `sylpheed-cli` agreed with the port here because it agreed about
|
||||
/// `implied_layer_key`.
|
||||
///
|
||||
/// Two measured controls, both prior orders off the running game:
|
||||
///
|
||||
/// | primitive | measured | opaque instants | forced below | |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
|
||||
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
|
||||
///
|
||||
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
|
||||
/// matches 77 of 80 and fails on exactly the three families that cross it —
|
||||
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
|
||||
/// an overlay* and is measured painting first. The name is not the rule.
|
||||
///
|
||||
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
|
||||
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
|
||||
/// `.t32` textures must sort first *against their own layer keys*. **An
|
||||
/// element's alpha says nothing about whether its texture covers the screen** —
|
||||
/// most of a sprite may be transparent.
|
||||
///
|
||||
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
|
||||
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
|
||||
/// ordering — it says nothing about elements that are constrained but not
|
||||
/// forced. Delete this when a pinned `sylpheed-formats` does it.
|
||||
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
|
||||
let screen_end: i64 = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
|
||||
.map(i64::from)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let forced: Vec<usize> = elements
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
|
||||
// sprite". Those coincide in GP_TITLE and the distinction is still
|
||||
// the whole point, because the negative test guards a SYMPTOM.
|
||||
//
|
||||
// The rule needs the element's alpha to BE its pixels' alpha. That
|
||||
// is true of a `.prm` solid quad and of nothing else. The Decoder
|
||||
// found this the expensive way twice: first `.t32` sprites (an
|
||||
// element's alpha says nothing about a texture that is mostly
|
||||
// transparent), guarded with "no sprite" -- and then `.tbm`, which
|
||||
// is 38 of their 80 forced-first verdicts and declares fade
|
||||
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
|
||||
// make the screen white; no screen is white, so a `.tbm`'s white is
|
||||
// a modulation ON a texture and its element alpha proves nothing
|
||||
// about coverage either.
|
||||
//
|
||||
// "No sprite" would keep admitting a `.tbm` that this exporter
|
||||
// happens not to emit a sprite for. `role == "primitive"` cannot.
|
||||
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
|
||||
// full-screen element here is `.prm` and pure black, checked -- so
|
||||
// this changes no verdict today and is a guard against a corpus
|
||||
// that grows.
|
||||
// Cheap prefilter only -- the binding coverage test is per-instant,
|
||||
// in `covers` below. An element scaled ABOVE 100 could cover the
|
||||
// screen from a smaller declared size, so this deliberately does
|
||||
// not reject on size.
|
||||
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
|
||||
})
|
||||
.filter(|(i, e)| {
|
||||
let span: Vec<i64> = e
|
||||
.keyframes
|
||||
.iter()
|
||||
.filter_map(|k| k.t)
|
||||
.map(i64::from)
|
||||
.collect();
|
||||
let Some(&lo) = span.first() else { return false };
|
||||
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
|
||||
// Declared size alone is not what the element draws: scale is a
|
||||
// percent per axis and it animates. `pbafc.prm` is the disc's own
|
||||
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
|
||||
// about 17x18 px, a moving glint rather than a wash. A rule that
|
||||
// read its declared size would call it screen-covering.
|
||||
//
|
||||
// Nothing in GP_TITLE needs this: every layerless full-screen
|
||||
// element here is at scale 100 on every keyframe, so no verdict
|
||||
// moves. It is in because the data that would break it exists on
|
||||
// this disc, which is a better reason than a failure would have been.
|
||||
let covers = |t: i64| {
|
||||
let sc = scale_at(e, t);
|
||||
e.size.is_some_and(|s| {
|
||||
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
|
||||
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
|
||||
})
|
||||
};
|
||||
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
|
||||
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
|
||||
// that shows why: it declares ONE keyframe, opaque black full-screen
|
||||
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
|
||||
// a single-instant event instead of the thing that is on screen for
|
||||
// the whole splash. So the span runs to the SCREEN's last keyframe.
|
||||
let hi = screen_end.max(*span.last().unwrap());
|
||||
let opaque: Vec<i64> = (lo..=hi)
|
||||
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Every OTHER element must be visible somewhere inside that span.
|
||||
elements.iter().enumerate().all(|(j, o)| {
|
||||
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if forced.is_empty() {
|
||||
return order;
|
||||
}
|
||||
let mut out = forced.clone();
|
||||
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user