diff --git a/Cargo.lock b/Cargo.lock index f919478..214307f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index 45b25dd..6b08a7a 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -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" diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs index abbeb52..7a7e9e2 100644 --- a/crates/sylpheed-export/src/check.rs +++ b/crates/sylpheed-export/src/check.rs @@ -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 { diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 7817ace..9b128f0 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -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(()) } } diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 6f24ac9..9273585 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -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, } +/// 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, + pub pivot: [u32; 2], + pub rest: Rest, + pub keyframes: Vec, +} + +#[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, +} + #[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, + /// 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, /// 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 = BTreeMap::new(); let mut missing = Vec::new(); - let mut write_sprite = |sprite: &str| -> Result { + // 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, + sprite: &str, + bytes: &[u8], + map: &std::collections::HashMap, + ) -> Result { 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 { @@ -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 { diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 446ebd9..7f9184a 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -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 diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 1929053..ea3d141 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -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 = "" diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 08cb8f6..5b111f9 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -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