Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
476 lines
20 KiB
Rust
476 lines
20 KiB
Rust
//! One UI build → one `sylpheed.screen/3` JSON document plus its sprite PNGs.
|
||
//!
|
||
//! Everything here is **derived**: it is what the bundle says, restated in a
|
||
//! format Godot can read. The two places a value is not read off the disc are
|
||
//! marked in the output itself — `name_source` when a screen's name came from
|
||
//! `authored/`, and `layer_source: "implied"` when the paint-order key came from
|
||
//! the decoders' measured table rather than from a `T8aD` header. A consumer can
|
||
//! tell the difference without reading this file.
|
||
|
||
use anyhow::{Context, Result};
|
||
use serde::Serialize;
|
||
use std::collections::BTreeMap;
|
||
use std::path::Path;
|
||
use sylpheed_formats::{t8ad, ui_layout};
|
||
|
||
/// `elements[].role`, from the decoded element kind.
|
||
///
|
||
/// ⚠️ `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.
|
||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||
match kind {
|
||
0x3002 => "button",
|
||
0x10 if !has_sprite => "primitive",
|
||
0x0 => "decoration",
|
||
_ => "unknown",
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Source {
|
||
/// Path of the archive within the disc root.
|
||
pub archive: String,
|
||
/// Pak **entry index** — the stable locator, not the display ordinal.
|
||
pub entry: usize,
|
||
/// Index into this pak's list of screen builds (what `screen --build` takes).
|
||
pub build: usize,
|
||
}
|
||
|
||
/// A placement keyframe, carrying the on-disc time verbatim.
|
||
///
|
||
/// `t` is in the disc's own units and is deliberately **not** converted here:
|
||
/// the seconds conversion is measured off the running game, not read from the
|
||
/// file, so it lives in `authored/timing.json` and is applied in exactly one
|
||
/// place. See HANDOFF Q1.
|
||
#[derive(Serialize)]
|
||
pub struct Keyframe {
|
||
/// On-disc time, absent on the final keyframe of a group — which carries no
|
||
/// time slot at all. Absent, never invented.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub t: Option<u32>,
|
||
/// Top-left of the element at 1:1. Signed: elements animate in from off-screen.
|
||
pub pos: [i32; 2],
|
||
/// Percent, per axis. Scale grows the element **about its pivot**, not about
|
||
/// `pos` — at 100 % the two are identical, which is why it went unnoticed.
|
||
pub scale: [u32; 2],
|
||
/// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every
|
||
/// keyframe on the disc.
|
||
pub tint_rgba: String,
|
||
/// The second modulate colour, **ARGB** byte order — the high byte is the
|
||
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
|
||
pub fade_argb: String,
|
||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||
/// different screens and different elements: the title's `ptloop` sweeps
|
||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||
/// pivot**, also measured.
|
||
pub rotation_deg: i32,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Rest {
|
||
pub pos: [i32; 2],
|
||
pub scale: [u32; 2],
|
||
pub tint_rgba: String,
|
||
pub fade_argb: String,
|
||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||
/// different screens and different elements: the title's `ptloop` sweeps
|
||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||
/// pivot**, also measured.
|
||
pub rotation_deg: i32,
|
||
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub t: Option<u32>,
|
||
}
|
||
|
||
/// One element of a button's focused-state record.
|
||
///
|
||
/// A focus record is **not** a single sprite. `ptbtn0Nf.rat` declares the
|
||
/// spinning ring `ptbtneff01.t32` *and* the bright label, and the parent bundle
|
||
/// declares **no element for the record at all** — so the leaf is the only
|
||
/// source of placement for both, and the parent has nothing to inherit from.
|
||
/// That is why these carry their own `pos`, and why they are not simply a
|
||
/// second sprite path on the base element.
|
||
#[derive(Serialize)]
|
||
pub struct FocusElement {
|
||
pub id: String,
|
||
pub declared: String,
|
||
pub sprite: Option<String>,
|
||
pub pivot: [u32; 2],
|
||
pub rest: Rest,
|
||
pub keyframes: Vec<Keyframe>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Focus {
|
||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||
pub record: String,
|
||
/// Back-to-front, in the leaf's own declaration order.
|
||
pub elements: Vec<FocusElement>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Element {
|
||
/// Declaration index — the key the placement region and `paint_order` use.
|
||
pub index: usize,
|
||
/// The declared name with its extension stripped; stable within a screen.
|
||
pub id: String,
|
||
/// The name exactly as the declaration table spells it.
|
||
pub declared: String,
|
||
pub role: &'static str,
|
||
pub kind_raw: String,
|
||
/// Sprite PNG, relative to `export/`. Absent for an untextured primitive.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub sprite: Option<String>,
|
||
/// The highlighted-state sprite: this element's sprite with an `f` before
|
||
/// the extension, when the bundle carries one — `ptbtn01.t32` ↔
|
||
/// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds
|
||
/// for all 54 real pairs on the disc (HANDOFF), and it is the only link
|
||
/// between a button and its highlight that has survived checking.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub focus_sprite: Option<String>,
|
||
/// The focused state, read from the element's `.rat` leaf. Supersedes
|
||
/// `focus_sprite`, which is kept because it is the 54-pair naming
|
||
/// convention and a consumer may still want the bare highlight texture.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub focus: Option<Focus>,
|
||
/// The raw `opt ` link inside this element's `.rat` record.
|
||
///
|
||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||
/// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the
|
||
/// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||
/// decorations and into a button. It is carried through unresolved and
|
||
/// unnamed so that whoever decodes it has it, and so that nothing downstream
|
||
/// mistakes it for navigation.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub opt_link: Option<String>,
|
||
pub pivot: [u32; 2],
|
||
/// Untextured primitives have no texture to take a size from; the quad is
|
||
/// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub size: Option<[u32; 2]>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub parent: Option<usize>,
|
||
/// 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.
|
||
pub layer_source: &'static str,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub layer: Option<String>,
|
||
/// This element is another element's focused state, not a screen element.
|
||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||
pub focused: bool,
|
||
/// A `loopN` sprite animation rather than a placed element.
|
||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||
pub animated: bool,
|
||
/// The resting pose: the **hold**, the longest run of consecutive keyframes
|
||
/// with an identical pose that does not end the group. Neither the first nor
|
||
/// the last keyframe.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub rest: Option<Rest>,
|
||
pub keyframes: Vec<Keyframe>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct Screen {
|
||
pub format: &'static str,
|
||
pub exporter: String,
|
||
/// Revision of `sylpheed-formats` whose decoders produced this file.
|
||
pub formats_rev: &'static str,
|
||
pub source: Source,
|
||
pub name: String,
|
||
/// `"authored"` when the name came from `authored/screen_names.json`,
|
||
/// `"index"` when nobody has named this build yet.
|
||
pub name_source: &'static str,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub name_why: Option<String>,
|
||
pub design: [u32; 2],
|
||
pub elements: Vec<Element>,
|
||
/// Back-to-front paint order as declaration indices, from the decoded `u16`
|
||
/// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys
|
||
/// keep declaration order. See `unresolved: paint_order_ties`.
|
||
pub paint_order: Vec<usize>,
|
||
/// Navigation order: `button`-role elements sorted by resting Y.
|
||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||
/// and not to be trusted for anything else.
|
||
pub buttons: Vec<String>,
|
||
/// What this file does not answer. A consumer needing one of these must get
|
||
/// it from `authored/`.
|
||
pub unresolved: Vec<&'static str>,
|
||
}
|
||
|
||
fn hex32(v: u32) -> String {
|
||
format!("0x{v:08x}")
|
||
}
|
||
|
||
/// Strip the extension the declaration table spells, giving a stable id.
|
||
pub fn id_of(declared: &str) -> String {
|
||
declared
|
||
.rsplit_once('.')
|
||
.map(|(stem, _)| stem)
|
||
.unwrap_or(declared)
|
||
.to_string()
|
||
}
|
||
|
||
/// What one screen's export produced, for the manifest.
|
||
pub struct Exported {
|
||
pub name: String,
|
||
pub json_path: String,
|
||
pub sprites: usize,
|
||
/// Sprites an element named that did not resolve or decode.
|
||
pub missing: Vec<String>,
|
||
}
|
||
|
||
/// Convert one build to JSON on disk, writing its sprite PNGs beside it.
|
||
///
|
||
/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not
|
||
/// across builds, and two screens' `ptbase.t32` are different pictures.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn export_build(
|
||
out: &Path,
|
||
archive: &str,
|
||
entry: usize,
|
||
build_idx: usize,
|
||
bundle: &[u8],
|
||
name: &str,
|
||
name_source: &'static str,
|
||
name_why: Option<String>,
|
||
subdir: &str,
|
||
exporter: &str,
|
||
formats_rev: &'static str,
|
||
) -> Result<Exported> {
|
||
let b = ui_layout::parse_build(bundle).context("build did not parse")?;
|
||
|
||
// Every sprite an element actually references, decoded once and written as a
|
||
// PNG under this screen's own directory.
|
||
let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite));
|
||
let sprite_dir = out.join("sprites").join(subdir).join(name);
|
||
std::fs::create_dir_all(&sprite_dir)?;
|
||
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
||
let mut missing = Vec::new();
|
||
// Decode one T8aD and write it, from whichever bundle slice and sprite map
|
||
// owns it. A leaf's sprites may be indexed in the leaf's own map (offsets
|
||
// relative to the leaf slice) or in the parent's; the caller says which.
|
||
fn write_from(
|
||
dir: &Path,
|
||
written: &mut BTreeMap<String, ()>,
|
||
sprite: &str,
|
||
bytes: &[u8],
|
||
map: &std::collections::HashMap<String, (usize, usize)>,
|
||
) -> Result<bool> {
|
||
if written.contains_key(sprite) {
|
||
return Ok(true);
|
||
}
|
||
let Some(&(off, size)) = map.get(sprite) else {
|
||
return Ok(false);
|
||
};
|
||
let Some(img) = t8ad::parse(&bytes[off..off + size]) else {
|
||
return Ok(false);
|
||
};
|
||
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
|
||
.context("T8aD dimensions disagree with its pixel count")?;
|
||
buf.save(dir.join(format!("{}.png", id_of(sprite))))?;
|
||
written.insert(sprite.to_string(), ());
|
||
Ok(true)
|
||
}
|
||
|
||
|
||
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
|
||
fn highlight_name(sprite: &str) -> Option<String> {
|
||
let (stem, ext) = sprite.rsplit_once('.')?;
|
||
Some(format!("{stem}f.{ext}"))
|
||
}
|
||
|
||
let mut elements = Vec::new();
|
||
for el in &b.elements {
|
||
let mut sprite_out = None;
|
||
if let Some(s) = &el.sprite {
|
||
if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? {
|
||
sprite_out = Some(sprite_rel(s));
|
||
} else {
|
||
missing.push(s.clone());
|
||
}
|
||
}
|
||
// The highlight pairs by NAME on the sprite, not through the `opt `
|
||
// link: `opt ` is refuted as a focus link and points somewhere else
|
||
// entirely on half these elements.
|
||
let mut focus_sprite = None;
|
||
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
||
if write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? {
|
||
focus_sprite = Some(sprite_rel(&h));
|
||
}
|
||
}
|
||
|
||
// The focused state is a RECORD, not a sprite. `ptbtn0Nf.rat` declares
|
||
// the spinning ring AND the bright label, and the parent bundle
|
||
// declares no element for it at all -- so the leaf is the only source
|
||
// of placement for both, and there is nothing for it to inherit.
|
||
//
|
||
// 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.
|
||
let mut focus = None;
|
||
if let Some(rec) = highlight_name(&el.name) {
|
||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||
if let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) {
|
||
let mut fes = Vec::new();
|
||
for fe in &leaf.elements {
|
||
// A leaf element's own NAME is its sprite -- `el.sprite`
|
||
// is only populated for a T8aD child of the same bundle,
|
||
// and these are indexed either in the leaf's map (offsets
|
||
// into the leaf slice) or in the parent's.
|
||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||
let mut fsprite = None;
|
||
if write_from(&sprite_dir, &mut written, sp,
|
||
&bundle[off..off + size], &leaf.sprites)?
|
||
|| write_from(&sprite_dir, &mut 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,
|
||
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(),
|
||
});
|
||
}
|
||
if !fes.is_empty() {
|
||
focus = Some(Focus { record: rec, elements: fes });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) {
|
||
Some(k) => (Some(hex32(k)), "sprite"),
|
||
None => match ui_layout::implied_layer_key(&el.name) {
|
||
Some(k) => (Some(hex32(k)), "implied"),
|
||
None => (None, "none"),
|
||
},
|
||
};
|
||
|
||
let kf = |k: &ui_layout::Keyframe| 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,
|
||
};
|
||
let role = role_of(el.kind, el.sprite.is_some());
|
||
elements.push(Element {
|
||
index: el.index,
|
||
id: id_of(&el.name),
|
||
declared: el.name.clone(),
|
||
role,
|
||
kind_raw: format!("{:#x}", el.kind),
|
||
sprite: sprite_out,
|
||
focus_sprite,
|
||
focus,
|
||
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,
|
||
layer_source,
|
||
layer,
|
||
focused: el.focused,
|
||
animated: el.animated,
|
||
rest: el.rest().map(|k| Rest {
|
||
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,
|
||
t: k.time,
|
||
}),
|
||
keyframes: el.keyframes.iter().map(kf).collect(),
|
||
});
|
||
}
|
||
|
||
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
|
||
// focused-state record is not itself a menu item.
|
||
let mut buttons: Vec<(i32, String)> = b
|
||
.elements
|
||
.iter()
|
||
.filter(|e| e.kind == 0x3002 && !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 screen = Screen {
|
||
format: "sylpheed.screen/3",
|
||
exporter: exporter.to_string(),
|
||
formats_rev,
|
||
source: Source {
|
||
archive: archive.to_string(),
|
||
entry,
|
||
build: build_idx,
|
||
},
|
||
name: name.to_string(),
|
||
name_source,
|
||
name_why,
|
||
design: [b.design_w, b.design_h],
|
||
elements,
|
||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||
unresolved: vec![
|
||
// The time unit is measured off the running game, not on the disc.
|
||
"keyframe_time_unit",
|
||
// Where two elements share a layer key the game's order is
|
||
// unexplained; eight candidates refuted. Costs one element's blend
|
||
// on one screen.
|
||
"paint_order_ties",
|
||
// The last keyframe of a group carries no time slot, so the
|
||
// fade-OUT length is not in the file.
|
||
"fade_out_duration",
|
||
],
|
||
};
|
||
|
||
let dir = out.join("screens").join(subdir);
|
||
std::fs::create_dir_all(&dir)?;
|
||
let json_path = format!("screens/{subdir}/{name}.json");
|
||
std::fs::write(
|
||
out.join(&json_path),
|
||
format!("{}\n", serde_json::to_string_pretty(&screen)?),
|
||
)?;
|
||
|
||
missing.sort();
|
||
missing.dedup();
|
||
Ok(Exported {
|
||
name: name.to_string(),
|
||
json_path,
|
||
sprites: written.len(),
|
||
missing,
|
||
})
|
||
}
|