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.
This commit is contained in:
Sylpheed port agent
2026-08-29 08:27:41 +00:00
parent 3588a0270d
commit 0d2bf1f241
4 changed files with 214 additions and 38 deletions

View File

@@ -78,7 +78,11 @@ struct Manifest {
warnings: Vec<String>,
}
/// 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<String, std::collections::BTreeMap<String, NameEntry>>;
#[derive(serde::Deserialize)]
@@ -96,6 +100,8 @@ fn load_names(authored: &Path) -> Result<NameMap> {
#[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<NameMap> {
.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<String, std::collections::BTreeMap<String, NameEntry>>;
fn load_also_export(authored: &Path) -> Result<AlsoExport> {
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::<File>(&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<u8>)> {
/// 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<String, NameEntry>>)
-> Vec<(usize, Vec<u8>)>
{
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(),
],
};