diff --git a/authored/rendering.json b/authored/rendering.json new file mode 100644 index 00000000..c07df108 --- /dev/null +++ b/authored/rendering.json @@ -0,0 +1,42 @@ +{ + "format": "sylpheed.rendering/1", + "_": [ + "WHICH decoded rules the runtime applies where. AUTHORED because it is a", + "choice about the REACH of somebody else's decode, not about the disc.", + "Delete an entry the day the decode covers the case outright.", + "", + "The exporter flags `leaf_carries_geometry` on 15 elements -- those whose", + "nested `.rat` leaf declares a scale or rotation the parent does not. That", + "flag is a CENSUS FACT and it is emitted for all 15. What is DECODED is", + "narrower: the Decoder fitted the game's own composed alpha (per-draw vertex", + "colours C3FFFFFF / B6FFFFFF = 195 and 182) against the ptloop leaves and got", + "one consistent time, then PREDICTED the quad centres to ~11 px. That covers", + "`ptloop01` and `ptloop02` and nothing else." + ], + "draw_leaf_for": ["ptloop01", "ptloop02"], + "draw_leaf_why": [ + "The two the decode covers. `docs/re/structures/ui-leaf-vs-parent-alpha.md`.", + "", + "NOT DRAWN, though the exporter flags them and ships their data:", + "", + " `title_jp/ptlogo_eff2` -- parent 125% at (412,96), leaf 100% at (402,66).", + " It is the same SHAPE as the ptloop case and it is the element DECISIONS has", + " recorded since P1 as the largest render disagreement in the export. The", + " Decoder said in as many words: 'I have not tested it.' Drawing it would be", + " extending a decode past the case it was fitted on, which is the failure", + " this port has made four times this session.", + "", + " `build_12,15/pgloading_loop5` -- leaf scale (0,0). A zero scale is one of", + " the three historical failures this corpus names, so it is exactly where a", + " guess is most expensive.", + "", + "AND THERE IS NO WAY TO ADJUDICATE EITHER HERE. `title_jp` has no oracle", + "capture, and `verify-screen` compares against `sylpheed-cli`, which does not", + "draw leaves at all -- so ANY leaf drawing increases that divergence whether", + "it is right or wrong. Its max went 155 -> 232 when they were drawn, and that", + "number is not evidence in either direction.", + "", + "What deletes this list: a decode covering those cases, or an oracle capture", + "of title_jp." + ] +} diff --git a/crates/sylpheed-export/examples/rat_leaf.rs b/crates/sylpheed-export/examples/rat_leaf.rs index cf07e222..908e3132 100644 --- a/crates/sylpheed-export/examples/rat_leaf.rs +++ b/crates/sylpheed-export/examples/rat_leaf.rs @@ -20,7 +20,7 @@ fn main() { for el in &b.elements { if !el.name.starts_with("ptloop") { continue; } let r = el.rest(); - println!("\nPARENT {} -> rest scale {:?} rot {:?}", el.name, + println!("\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", el.name, el.sprite, r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg)); if let Some(&(off, size)) = b.records.get(&el.name) { match ui_layout::parse_build(&bundle[off..off + size]) { diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 18a9d7ea..50953594 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -166,6 +166,34 @@ pub struct Element { /// known. #[serde(skip_serializing_if = "Option::is_none")] pub leaf: Option, + + /// True when the leaf's geometry DIFFERS from the parent's, so the leaf is + /// what the game draws. + /// + /// Decided here rather than in the runtime because it is disc knowledge. + /// The Decoder's rule: *"the discriminator is which record carries the + /// geometry, not a fixed order"* — and the census over this export splits + /// cleanly, with no ambiguous middle: + /// + /// * **30 of 46** leaf elements duplicate the parent's scale and rotation + /// exactly. That is the BASE-record case `screen.rs` already handled: the + /// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf + /// y=402) and the parent wins. Flag is false; nothing changes. + /// * **16 differ**, and all of them differ in scale or rotation, not by a + /// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30° + /// and (100,800) at −45° against an identity parent), two + /// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s + /// `ptlogo_eff2` (**parent 125 %, leaf 100 %**). + /// + /// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's + /// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and + /// 182 — against the two leaf ramps and got one consistent time, t=355, then + /// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2 + /// measured. The other two are the same shape and are **not** separately + /// confirmed; they are flagged so the harness can adjudicate them rather + /// than being asserted. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub leaf_carries_geometry: bool, /// The raw `opt ` link inside this element's `.rat` record. /// /// ⚠️ **This is not a focus link.** It was read as one, and that was @@ -485,6 +513,15 @@ pub fn export_build( sprite: sprite_out, focus_sprite, focus, + leaf_carries_geometry: leaf.as_ref().is_some_and(|l| { + let p = el.rest(); + l.elements.iter().any(|le| { + p.is_none_or(|p| { + le.rest.scale != [p.scale_x, p.scale_y] + || le.rest.rotation_deg != p.rotation_deg + }) + }) + }), leaf, opt_link: el.focus_link.clone(), pivot: [el.pivot_x, el.pivot_y], diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 279ee4da..4d41cc7f 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -3996,3 +3996,61 @@ cancelling, the same shape as mine. It is a **shared decode gap, not a defect in my compositor**, and MISSION's *"Needs a human decision — rotation"* now has a number attached: **1.82 % of the title's pixels, in a signature that can be recognised.** + +## The leaf composition is decoded and implemented — and it does **not** close the 1.82 % + +The Decoder decoded the rule I refused to guess: **draw the leaf on its own +timeline; do not multiply the parent's alpha in.** Multiplying is *refuted*, not +merely unsupported — at the fitted time the parent has expired (its group returns +to 0 at t=250 and holds), so `leaf × parent / 255` predicts zero for both quads +and the sweeps would be invisible. They are drawn. + +The fit is worth repeating because of its shape: the game's own composed alpha is +observable in the per-draw vertex colours (`C3FFFFFF`/`B6FFFFFF` = **195** and +**182**), fitting *only those two numbers* against the two leaf ramps gives one +consistent time **t = 355**, and the same t then **predicts** quad centres at 981 +and 478 against **992.0** and **467.2** measured. No x entered the fit. + +Implemented: `_draw_leaf` runs the leaf unclamped — like the spinning ring, and +for the same reason. Held at its own `rest.t` the leaf sits at **x = 1521**, +entirely off the right edge, so `holding` would delete the sweeps rather than +settle them. + +### 🔴 And it changes nothing measurable. The title is still 1.82 % + +| t | units | differing | +|---|---|---| +| 4.35 s | 261 | 1.82 % | +| **5.917 s** | **355** | **1.81 %** | +| 7.00 s | 420 | 1.79 % | + +**At t=355 my interpolation puts the leaf's top-left at x ≈ −324 — off-screen +left — where the Decoder's model puts the quad's centre at 981.** Those cannot +both be right, and the gap is not something to tune away: it is a disagreement +about how the leaf's keyframes become a placed quad, most likely in the pivot and +the rotation about it. **Handed back with both numbers.** + +So the honest state: the exporter no longer drops the data, the composition rule +is implemented as decoded, and **the port's largest oracle gap is exactly where +it was.** Fixing the export was necessary and was not sufficient. + +### Two flagged elements deliberately not drawn + +`authored/rendering.json` narrows drawing to `ptloop01`/`ptloop02` — what the +decode covers — while the exporter keeps flagging all 15. + +* **`title_jp/ptlogo_eff2`** — parent 125 % at (412,96), leaf 100 % at (402,66). + The same shape, and the element `DECISIONS` has recorded since P1 as the + largest render disagreement in the export. The Decoder said plainly *"I have + not tested it."* Drawing it would extend a decode past the case it was fitted + on — the failure this port has made four times this session. +* **`pgloading_loop5`** — leaf scale **(0, 0)**. A zero scale is one of the three + historical failures this corpus names, so it is exactly where a guess costs + most. + +⚠️ **And neither can be adjudicated here.** `title_jp` has no oracle capture, and +`verify-screen` compares against `sylpheed-cli`, **which draws no leaves at all** +— so *any* leaf drawing increases that divergence whether it is right or wrong. +Its `max` went 155 → 232 when they were drawn, and **that number is not evidence +in either direction.** Recognising it as uninformative is the reason it did not +become a justification. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index ccdeb7f6..0d322880 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -182,6 +182,9 @@ func _ready() -> void: # own logic on purpose -- see `looping_focus` there for the census that says # this cannot be a rule. _looping = timing.get("looping_focus_records", {}) + # Which decoded rules apply where. See `authored/rendering.json`. + var rendering: Variant = export_tree.authored("rendering.json") + _draw_leaf_for = [] if rendering == null else rendering.get("draw_leaf_for", []) # `--no-hold` plays a screen's groups PAST their rest instead of clamping each # element at its own `rest.t`. A diagnostic, not a mode: `rest.t` is the last # HOLD keyframe before the exit, not the settled state, and the only way to @@ -197,6 +200,7 @@ func _ready() -> void: viewport.add_child(view) view.looping_focus = _looping_for(name) + view.draw_leaf_for = _draw_leaf_for if not view.load_screen(export_tree, name): push_error(export_tree.error) get_tree().quit(2) @@ -260,6 +264,8 @@ var _play := false var _pending: Variant = null ## `authored/timing.json` `looping_focus_records`, keyed `/`. var _looping: Dictionary = {} +## `authored/rendering.json` `draw_leaf_for`. +var _draw_leaf_for: Array = [] var _script: PackedStringArray = PackedStringArray() var _shots := "" ## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0. @@ -360,6 +366,7 @@ func _advance() -> void: view.holding = true view.time_units = 0.0 view.looping_focus = _looping_for(name) + view.draw_leaf_for = _draw_leaf_for if not view.load_screen(view.tree, name): push_error(view.tree.error) get_tree().quit(2) @@ -562,6 +569,7 @@ func _menu_arrive() -> void: view.holding = true view.time_units = 0.0 view.looping_focus = _looping_for(name) + view.draw_leaf_for = _draw_leaf_for if not view.load_screen(view.tree, name): push_error(view.tree.error) get_tree().quit(2) @@ -876,6 +884,7 @@ func _raise_overlay(name: String) -> void: overlay.units_per_second = view.units_per_second overlay.exit_ramp_units = view.exit_ramp_units overlay.looping_focus = _looping_for(name) + overlay.draw_leaf_for = _draw_leaf_for overlay.holding = true overlay.time_units = 0.0 if not overlay.load_screen(view.tree, name): diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 60f42d98..bb47fe96 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -58,6 +58,17 @@ var exit_ramp_units: float = 24.0 ## rule's clothes. var looping_focus: Dictionary = {} +## Element ids whose nested `.rat` leaf the runtime actually draws. +## +## The exporter flags `leaf_carries_geometry` on 15 elements -- a census fact. +## This is narrower on purpose: it is the subset the DECODE covers, and it comes +## from `authored/rendering.json` with its reasons. Two elements are flagged and +## deliberately not drawn (`title_jp/ptlogo_eff2`, `pgloading_loop5`), because +## drawing them would extend a decode past the case it was fitted on and neither +## can be adjudicated here -- `title_jp` has no oracle capture, and the +## consistency harness compares against a renderer that draws no leaves at all. +var draw_leaf_for: Array = [] + ## While true the screen holds at `rest` and never plays its exit. The ## sequencer clears it to send the screen away. var holding: bool = true @@ -99,6 +110,8 @@ func _load_textures() -> void: 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("leaf", {}).get("elements", []): + paths.append(fe.get("sprite", "")) for fe: Dictionary in element.get("focus", {}).get("elements", []): paths.append(fe.get("sprite", "")) for rel: String in paths: @@ -367,6 +380,54 @@ static func _rot_of(pose: Dictionary) -> float: ## 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. +## Draw an element's nested `.rat` leaf INSTEAD of the element itself. +## +## Only when the exporter flagged `leaf_carries_geometry` -- 15 elements, where +## the leaf's scale or rotation differs from the parent's. Everywhere else the +## leaf duplicates the parent and the parent wins, which is what this port has +## always done and which `screen.rs` documents for base records. +## +## ⚠️ **The leaf runs on its OWN timeline and the parent's alpha is NOT +## multiplied in.** That is decoded, not assumed, and multiplying is refuted +## rather than merely unsupported: the game's own composed alpha is observable in +## the per-draw capture's vertex colours (`C3FFFFFF` / `B6FFFFFF` = 195 and 182), +## and fitting only those two numbers against the two leaf ramps gives one +## consistent time, t=355 -- leaf A 194.8 against 195, leaf B 182.2 against 182. +## At t=355 the PARENT has expired: its group returns to 0 at t=250 and holds +## there, so `leaf x parent / 255` predicts zero for both quads and the sweeps +## would be invisible. They are drawn. +## +## The check that matters was PREDICTED, not fitted: no x entered it, and the +## same t=355 places the quad centres at 981 and 478 against 992.0 and 467.2 +## measured off the capture -- ~11 px on quads travelling 1 560 and 1 950 px. +## +## ❔ Every observation behind this has parent alpha 0, so "the leaf wins" and +## "the parent is ignored because it draws nothing" are NOT separated. A capture +## during t=100...238 would separate them. +func _draw_leaf(element: Dictionary) -> void: + for fe: Dictionary in element.get("leaf", {}).get("elements", []): + var rel: String = fe.get("sprite", "") + if rel == "": + continue + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (leaf sprite failed to load)" % fe.get("id", "")) + continue + # UNCLAMPED, like the spinning ring and for the same reason: a sweep that + # crosses the frame does not stop because the screen has arrived, and + # `ORACLE-CAPTURES.md` says these two "move continuously". Held at its own + # `rest.t` the leaf sits at x=1521 -- entirely off the right edge -- so + # `holding` would delete the sweeps rather than settle them. + var was := holding + holding = false + var pose := pose_at(fe, time_units) + holding = was + var pivot := _vec(fe.get("pivot", [0, 0])) + _draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose), + pivot, _vec(pose.get("pos", [0, 0])), _rot_of(pose)) + drawn.append(fe.get("id", "")) + + func _draw_focus(element: Dictionary) -> void: var focus: Dictionary = element.get("focus", {}) var parent_id := String(element.get("id", "")) @@ -443,6 +504,13 @@ func _draw() -> void: var pivot := _vec(element.get("pivot", [0, 0])) var pos := _vec(pose.get("pos", [0, 0])) var rot := _rot_of(pose) + # An element whose LEAF carries the geometry draws the leaf instead of + # itself: the parent is a container whose own record has identity scale + # and rotation. See `_draw_leaf`. + if element.get("leaf_carries_geometry", false) \ + and draw_leaf_for.has(String(element.get("id", ""))): + _draw_leaf(element) + continue # A focused button draws its own record instead of its base sprite -- and # so does an element the authored table says always shows it, which is # how the PRESS (A) plate gets its glow on a screen that has no buttons