FORMAT v3: rotation, and the focus record -- the ring the port could not reach
Pin bumped to the TAG formats-pin-2026-08-29 (7eeae30), applying the policy the
previous commit wrote. What I wanted from it: `UiBuild` gained a public
`records` map. Without it a leaf was unreachable through the public API --
parse_build sorted T8aD children into `sprites` and `.rat` children into a
PRIVATE map -- so the focus ring, which lives inside ptbtn0Nf.rat, a record the
parent bundle declares NO element for, could not be located at all. My exporter
was writing 19 of build 5's 21 sprites and I could not see why.
v3 carries two new things.
ROTATION. `rotation_deg`, decoded at keyframe +12, and the game DRAWS it --
confirmed twice by the RE agent on different screens with different elements:
the title's ptloop sweeps declare +30/-45 and a GPU capture submits them at
+30.26/-45.28, and the focus ring ramps 0 -> 360 with everything else constant,
caught mid-spin in a capture. Rotation is about the DECLARED PIVOT, measured.
The comparison renderer does not draw it yet, so a rotation disagreement means
sylpheed-cli is behind, not that the port is wrong. Sign is still an assumption.
THE FOCUS RECORD. A focused button is not a sprite swap: ptbtn0Nf.rat declares
the spinning ring AND the bright label, and since the parent declares no element
for the record, the leaf is the only source of placement for both. v2's single
focus_sprite could not carry the ring at all and drew the highlight 7 px
off-centre by inheriting the base position. That -7,-7 is load-bearing: the f
label is 13 px larger per axis and -7 keeps the two concentric.
Checked against the game, not against the other renderer: rendering main_menu
with OPTIONS focused changes the region x 504..703, y 399..448. The RE agent
measured the same difference in the live capture at x 505..703, y 397..446 --
independently, from the other side. Ring, label and underline all land; the only
visible residual is the ring's spin PHASE, which is exactly the one thing
neither of us has resolved (its second keyframe is untimed, and the screen-level
rule for that is not established to apply inside a leaf). Listed in `unresolved`
rather than invented.
verify-screen is unchanged at 16/16 -- rotation has no effect at rest on these
screens, as predicted.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -959,7 +959,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sylpheed-formats"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=f817dd5#f817dd59393b1437d4ea70b58124c1309f6c076f"
|
||||
source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?tag=formats-pin-2026-08-29#7eeae3006a7bb92ae31ba8d2b402891f3a31f070"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"binrw",
|
||||
|
||||
@@ -14,6 +14,23 @@ license.workspace = true
|
||||
# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the
|
||||
# continuous cutscene-voice stream). Do not re-derive those here.
|
||||
#
|
||||
# Pin moved f817dd5 -> 7eeae30 on 2026-08-29. WHAT I WANTED FROM IT: `UiBuild`
|
||||
# gained a public `records` map (record name -> the nested `.rat` leaf's byte
|
||||
# range). Without it a consumer could not locate a leaf at all: `parse_build`
|
||||
# sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map, so
|
||||
# the focus ring -- which lives inside `ptbtn0Nf.rat`, a record the parent
|
||||
# bundle declares no element for -- was unreachable through the public API.
|
||||
# Also brings `Keyframe::rotation_deg`, which the game does render.
|
||||
#
|
||||
# PINNED BY TAG, not by sha, and MISSION §2 now requires it. The reachability
|
||||
# risk this comment used to warn about is closed: a sha reachable only from an
|
||||
# `auto/*` branch is orphaned when that branch is deleted or -- worse --
|
||||
# SQUASH-MERGED, because squash creates new commits, so `main` would look like
|
||||
# it contained the work while this pin became unreachable. A tag is a permanent
|
||||
# ref, it says what it is here in the file, and it fails loudly at FETCH rather
|
||||
# than silently at build. `formats-pin-2026-08-29` is 7eeae30.
|
||||
#
|
||||
# Previous pin note, kept because the reasoning still holds:
|
||||
# Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`,
|
||||
# "a RATC child's name is stated, not inferred". A child was named by scanning
|
||||
# backwards for the last printable run before its magic; for `pteff05.t32` the
|
||||
@@ -32,7 +49,7 @@ license.workspace = true
|
||||
# which a capture of the running game plainly shows. 5414db3 is the revision at
|
||||
# which that fix carries its disc-wide check (30 of 13 991 elements move, 4
|
||||
# become visible, 0 become invisible), not merely the one where it was written.
|
||||
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "f817dd5" }
|
||||
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", tag = "formats-pin-2026-08-29" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -21,7 +21,7 @@ use anyhow::{bail, Result};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
const SCREEN_FORMAT: &str = "sylpheed.screen/2";
|
||||
const SCREEN_FORMAT: &str = "sylpheed.screen/3";
|
||||
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
|
||||
|
||||
struct Ctx {
|
||||
|
||||
@@ -186,7 +186,7 @@ fn main() -> Result<()> {
|
||||
} => run_export(&disc, &out, &authored),
|
||||
Cmd::Check { out } => {
|
||||
let n = check::run(&out)?;
|
||||
println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display());
|
||||
println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs.
|
||||
//! 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
|
||||
@@ -61,6 +61,14 @@ pub struct Keyframe {
|
||||
/// 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)]
|
||||
@@ -69,10 +77,45 @@ pub struct Rest {
|
||||
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.
|
||||
@@ -93,6 +136,11 @@ pub struct Element {
|
||||
/// 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
|
||||
@@ -207,22 +255,32 @@ pub fn export_build(
|
||||
std::fs::create_dir_all(&sprite_dir)?;
|
||||
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut write_sprite = |sprite: &str| -> Result<bool> {
|
||||
// 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)) = b.sprites.get(sprite) else {
|
||||
let Some(&(off, size)) = map.get(sprite) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
|
||||
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(sprite_dir.join(format!("{}.png", id_of(sprite))))?;
|
||||
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> {
|
||||
@@ -234,7 +292,7 @@ pub fn export_build(
|
||||
for el in &b.elements {
|
||||
let mut sprite_out = None;
|
||||
if let Some(s) = &el.sprite {
|
||||
if write_sprite(s)? {
|
||||
if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? {
|
||||
sprite_out = Some(sprite_rel(s));
|
||||
} else {
|
||||
missing.push(s.clone());
|
||||
@@ -245,11 +303,74 @@ pub fn export_build(
|
||||
// entirely on half these elements.
|
||||
let mut focus_sprite = None;
|
||||
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
||||
if b.sprites.contains_key(&h) && write_sprite(&h)? {
|
||||
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) {
|
||||
@@ -264,6 +385,7 @@ pub fn export_build(
|
||||
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 {
|
||||
@@ -274,6 +396,7 @@ pub fn export_build(
|
||||
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]),
|
||||
@@ -287,6 +410,7 @@ pub fn export_build(
|
||||
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(),
|
||||
@@ -304,7 +428,7 @@ pub fn export_build(
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/2",
|
||||
format: "sylpheed.screen/3",
|
||||
exporter: exporter.to_string(),
|
||||
formats_rev,
|
||||
source: Source {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# The open export format — v2
|
||||
# The open export format — v3
|
||||
|
||||
The format the disc is converted *into*, and the one the Godot project and any
|
||||
modding tool read. **It is versioned, so a change is a deliberate act with a
|
||||
version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the
|
||||
bottom, with a reason for each.
|
||||
version bump**, not a silent edit. [Changes from v2](#changes-from-v2) and
|
||||
[Changes from v1](#changes-from-v1) are at the bottom, with a reason for each.
|
||||
|
||||
Design rules, in priority order:
|
||||
|
||||
@@ -53,7 +53,7 @@ rest are applied by the runtime over `export/`.
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.screen/2",
|
||||
"format": "sylpheed.screen/3",
|
||||
"exporter": "sylpheed-export 0.1.0",
|
||||
"formats_rev": "8b6dbcf",
|
||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
|
||||
@@ -69,7 +69,7 @@ the index into that pak's list of screen builds (what `sylpheed-cli screen
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "sylpheed.screen/2",
|
||||
"format": "sylpheed.screen/3",
|
||||
"exporter": "sylpheed-export 0.1.0",
|
||||
"formats_rev": "8b6dbcf",
|
||||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
|
||||
@@ -154,6 +154,46 @@ pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which i
|
||||
naming convention that holds for all 54 real pairs on the disc, not a decoded
|
||||
field.
|
||||
|
||||
**`focus`** is the focused state, and it **supersedes `focus_sprite`**. A
|
||||
focused button is not a sprite swap: `ptbtn0Nf.rat` is a nested `.rat` **leaf**
|
||||
declaring *two* elements — 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 there is nothing for it to
|
||||
inherit.
|
||||
|
||||
```json
|
||||
"focus": {
|
||||
"record": "ptbtn04f.rat",
|
||||
"elements": [
|
||||
{ "id": "ptbtneff01", "sprite": "…/ptbtneff01.png", "pivot": [21, 23],
|
||||
"rest": { "pos": [500, 396], "rotation_deg": 0, "t": 120, … },
|
||||
"keyframes": [ { "t": 120, "rotation_deg": 0, … },
|
||||
{ "rotation_deg": 360, … } ] },
|
||||
{ "id": "ptbtn04f", "sprite": "…/ptbtn04f.png", "rest": { "pos": [535, 395], … } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Elements are back-to-front in the leaf's own declaration order — ring first,
|
||||
then label. Positions are **absolute design-space top-left**, not offsets from
|
||||
the button.
|
||||
|
||||
> The label's `(−7, −7)` against its base is load-bearing, not noise:
|
||||
> `ptbtn0Nf.t32` is 13 px larger per axis, and −7 keeps the two **concentric**
|
||||
> (535 + 96/2 = 583 against 542 + 83/2 = 583.5). Drawing the highlight at the
|
||||
> base position pushes it 7 px down-right and off-centre.
|
||||
|
||||
> ⚠️ **Leaf placement is authoritative for an `f` record and NOT for a base
|
||||
> record.** A base record's leaf *duplicates* its parent's placement and the two
|
||||
> can disagree by a unit (`ptbtn04`: parent y=401, leaf y=402) — there the parent
|
||||
> wins. The `f` record is the case where the parent declares nothing.
|
||||
|
||||
> ❔ **The ring spins, and its period is unresolved.** Its two keyframes differ
|
||||
> in `rotation_deg` alone, 0 → 360. But the second is untimed, and what an
|
||||
> untimed keyframe means *inside a leaf* — as opposed to at screen level, where
|
||||
> it is the exit ramp — is untested. A consumer should draw the resting angle
|
||||
> rather than invent a spin rate. This is listed in `unresolved`.
|
||||
|
||||
**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried
|
||||
through unresolved. ⚠️ **It is not a focus link.** That reading was measured and
|
||||
refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||||
@@ -177,6 +217,23 @@ there — and a file that puts one on it is wrong, not merely odd. The unit of `
|
||||
is measured, not on the disc, and so lives in `authored/` and is applied in
|
||||
exactly one place.
|
||||
|
||||
**`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive,
|
||||
decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by
|
||||
the RE agent 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 main menu's focus ring ramps 0 → 360 with position, scale, alpha
|
||||
and tint all constant — a capture caught it mid-spin.
|
||||
|
||||
Rotation is **about the declared pivot**, which is measured rather than assumed:
|
||||
the `ptloop` sweeps scale 600 %/800 % vertically, where the pivot term is worth
|
||||
450 and 630 px, and the capture puts both quad centres at y 359.1/360.0 against
|
||||
the pivot formula's 360.0 (top-left predicts 810/990, centre-as-position
|
||||
predicts 270).
|
||||
|
||||
> ⚠️ `sylpheed-cli screen render` does **not** draw rotation yet — its `blit` is
|
||||
> axis-aligned. A rotation disagreement between it and a consumer that does draw
|
||||
> rotation means the CLI is behind, not that the consumer is wrong.
|
||||
|
||||
**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially
|
||||
every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps
|
||||
during a fade. The byte order is in the key name because getting it backwards is
|
||||
@@ -260,6 +317,17 @@ reaches which entry is Q4 and is not).
|
||||
`video_transcode` will record the exact command so a modder can re-run it rather
|
||||
than reverse-engineer what was done. It is absent until P4 writes a video.
|
||||
|
||||
## Changes from v2
|
||||
|
||||
v2 was written before the keyframe's `+12` was decoded and before anyone could
|
||||
reach a `.rat` leaf through the public decoder API.
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `rotation_deg` on every keyframe and on `rest` | Decoded at keyframe `+12`, and **the game draws it** — confirmed on two different screens with two different elements against GPU captures. Dropping it would have made the focus ring's whole animation invisible. |
|
||||
| `focus` (a record with its own elements) added; `focus_sprite` kept but demoted | The focused state is two elements in a nested leaf, not one sprite. v2's single `focus_sprite` could not carry the ring at all, and drew the highlight label 7 px off-centre by inheriting the base's position. `focus_sprite` stays because it is still the 54-pair naming convention and a consumer may want the bare texture. |
|
||||
| `unresolved` gains `focus_ring_spin_period` | The ring's rotation ramps 0 → 360 across two keyframes whose second is untimed. The screen-level rule for an untimed keyframe (the exit ramp) is not established to apply inside a leaf, so the period is unknown and is not being invented. |
|
||||
|
||||
## Changes from v1
|
||||
|
||||
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class_name ExportTree
|
||||
extends RefCounted
|
||||
|
||||
const FORMAT_SCREEN := "sylpheed.screen/2"
|
||||
const FORMAT_SCREEN := "sylpheed.screen/3"
|
||||
const FORMAT_MANIFEST := "sylpheed.manifest/1"
|
||||
|
||||
var root: String = ""
|
||||
|
||||
@@ -80,8 +80,12 @@ func load_screen(t: ExportTree, name: String) -> bool:
|
||||
func _load_textures() -> void:
|
||||
textures.clear()
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
for key in ["sprite", "focus_sprite"]:
|
||||
var rel: String = element.get(key, "")
|
||||
var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")]
|
||||
# The focus record's own elements carry their own sprites -- the ring is
|
||||
# only reachable this way.
|
||||
for fe: Dictionary in element.get("focus", {}).get("elements", []):
|
||||
paths.append(fe.get("sprite", ""))
|
||||
for rel: String in paths:
|
||||
if rel != "" and not textures.has(rel):
|
||||
var tex := tree.texture(rel)
|
||||
if tex == null:
|
||||
@@ -191,6 +195,7 @@ static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
|
||||
"scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)],
|
||||
"tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f),
|
||||
"fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f),
|
||||
"rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f),
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +269,77 @@ func _template_instance_ids() -> Dictionary:
|
||||
return ghosts
|
||||
|
||||
|
||||
## Draw one textured or solid quad, rotated about its pivot.
|
||||
##
|
||||
## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left
|
||||
## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that
|
||||
## point is exactly the `pos - pivot*(s-1)` rule the placement already uses.
|
||||
##
|
||||
## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different
|
||||
## screens and different elements -- the title's `ptloop` sweeps declare +30/-45
|
||||
## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus
|
||||
## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a
|
||||
## capture. The comparison renderer does not draw it yet, so expect a title
|
||||
## divergence that means "sylpheed-cli is behind", not "the port is broken".
|
||||
##
|
||||
## 🟡 The SIGN is an assumption: the decoder documents `+12` as
|
||||
## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down
|
||||
## space, so this passes the value straight through. Not yet checked against a
|
||||
## capture at a known angle.
|
||||
func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2,
|
||||
pos: Vector2, rotation_deg: float) -> void:
|
||||
if is_zero_approx(rotation_deg):
|
||||
if tex != null:
|
||||
draw_texture_rect(tex, rect, false, colour)
|
||||
else:
|
||||
draw_rect(rect, colour, true)
|
||||
return
|
||||
var anchor := pos + pivot
|
||||
draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE)
|
||||
var local := Rect2(rect.position - anchor, rect.size)
|
||||
if tex != null:
|
||||
draw_texture_rect(tex, local, false, colour)
|
||||
else:
|
||||
draw_rect(local, colour, true)
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
static func _rot_of(pose: Dictionary) -> float:
|
||||
return float(pose.get("rotation_deg", 0))
|
||||
|
||||
|
||||
## Draw a focus record's own elements -- the spinning ring and the bright label.
|
||||
##
|
||||
## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements,
|
||||
## and the parent bundle declares NO element for the record at all, so the leaf
|
||||
## is the only source of placement for both and there is nothing to inherit.
|
||||
## The label is 13 px larger per axis than the base and sits at (-7,-7), which
|
||||
## keeps the two concentric; drawing it at the base position pushes it 7 px
|
||||
## down-right and off-centre.
|
||||
func _draw_focus(element: Dictionary) -> void:
|
||||
var focus: Dictionary = element.get("focus", {})
|
||||
for fe: Dictionary in focus.get("elements", []):
|
||||
var rel: String = fe.get("sprite", "")
|
||||
if rel == "":
|
||||
continue
|
||||
var tex: Texture2D = textures.get(rel)
|
||||
if tex == null:
|
||||
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
|
||||
continue
|
||||
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360
|
||||
# with position, scale and alpha all constant -- but the PERIOD is not
|
||||
# established: the ramp's second keyframe is untimed, and what an untimed
|
||||
# keyframe means inside a leaf (rather than at screen level, where it is
|
||||
# the exit) is untested. So this holds the resting angle and does not
|
||||
# invent a spin rate.
|
||||
var pose: Dictionary = fe.get("rest", {})
|
||||
var pivot := _vec(fe.get("pivot", [0, 0]))
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
|
||||
pivot, pos, _rot_of(pose))
|
||||
drawn.append(fe.get("id", ""))
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if screen.is_empty():
|
||||
return
|
||||
@@ -284,6 +360,12 @@ func _draw() -> void:
|
||||
skipped.append("%s (transparent at rest)" % id)
|
||||
continue
|
||||
var pivot := _vec(element.get("pivot", [0, 0]))
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
var rot := _rot_of(pose)
|
||||
# A focused button draws its own record instead of its base sprite.
|
||||
if focused_id == id and element.has("focus"):
|
||||
_draw_focus(element)
|
||||
continue
|
||||
var rel: String = element.get("sprite", "")
|
||||
if focused_id == id and element.get("focus_sprite", "") != "":
|
||||
rel = element["focus_sprite"]
|
||||
@@ -292,12 +374,12 @@ func _draw() -> void:
|
||||
if tex == null:
|
||||
skipped.append("%s (sprite failed to load)" % id)
|
||||
continue
|
||||
draw_texture_rect(tex, placement(pose, pivot, tex.get_size()), false, colour)
|
||||
_draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot)
|
||||
drawn.append(id)
|
||||
elif element.get("role", "") == "primitive" and element.has("size"):
|
||||
# A primitive has no texture; the quad is its declared size and its
|
||||
# colour is the pose's own modulate.
|
||||
draw_rect(placement(pose, pivot, _vec(element["size"])), colour, true)
|
||||
_draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot)
|
||||
drawn.append(id)
|
||||
else:
|
||||
# A .t32 element whose sprite the exporter could not produce. Saying
|
||||
|
||||
Reference in New Issue
Block a user