port(exporter): the archive list is data, not a hardcoded constant
crates/sylpheed-export read dat/GP_TITLE.pak and nothing else. 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 neither the disc nor the reader -- examples/probe_archives.rs finds screen builds in 24 archives using the EXISTING detector -- it was that constant. The list now comes from authored/screen_names.json export_archives, whose 'archives' map was ALREADY keyed by archive path. Absent, it defaults to dat/GP_TITLE.pak, so an old authored tree exports what it always did. Sprite groups are mapped per archive rather than derived from the filename: sprites live at sprites/<group>/<screen>/, unnamed builds are named build_NN by entry index which restarts at 0 in every archive, so two archives sharing a group would collide. An unmapped archive is rejected rather than given a directory a typo could invent. NO BEHAVIOUR CHANGE, and verified as such before anything is added: exported to a separate tree with the current authored data and diffed -- export/screens and export-probe/screens are IDENTICAL, manifest screen list identical. The archive list itself is unchanged in this commit. Adding one is next, and separately, so any regression is attributable to the archive rather than to the refactor.
This commit is contained in:
@@ -208,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>)>
|
||||
{
|
||||
@@ -244,9 +292,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
// 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![
|
||||
"GP_TITLE screen builds only. No other archive, and only the two movies \
|
||||
MISSION section 6 puts in scope."
|
||||
.into(),
|
||||
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. \
|
||||
@@ -290,60 +336,76 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
}
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user