From 7c918006e844ecdf50f2c86a329cda5d382f7e98 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 16:51:36 +0000 Subject: [PATCH] port: the PRESS (A) plate pulses -- authored per element, because 82 of 212 share its shape The human listed pulsation as first-class and the port drew nothing: the plate's focus record `ptbtn00f` was never reached, because press_start has no `buttons` and nothing is focused. That it LOOPS is measured -- the corpus timed the period four times (2.12 / 2.19 / 2.34 / 2.31 s) and you cannot measure a period unless the thing repeats. THE RULE I WAS GOING TO WRITE DIED IN THE CENSUS. The spinning ring is a rule in the renderer because it has a disc-wide check: 16 of 212 elements match its shape and all 16 are focus rings. The analogous shape for a pulse -- keyframes varying only in alpha, first alpha equal to last -- matches 82 OF 212, including ptcopyright, palogo_sqex, ptmsg and every _eff fade. A renderer rule on it would make the copyright notice pulse. Narrowed to focus records it matches exactly one distinct element, and a rule justified by n=1 is a special case wearing a rule's clothes. So it is a LOOKUP in authored/timing.json keyed /, with the census recorded beside it so nobody widens it later. The period is 129 units -- the element's own group under the port's existing model: last timed keyframe t=105 plus the authored exit_ramp_units of 24. No new constant. 2.150 s at 60 units/s, 2.295 s at the ~28.1 fps the emulator presents, against measurements of 2.12-2.34. IT IS A CHOICE AND THE ALTERNATIVE IS STATED: restarting at the group's first keyframe (t=6) instead of 0 gives 123 units = 2.050 / 2.189 s, also inside the measured spread. Nothing separates them. t=0 is taken because it is where every other group starts -- consistency, not evidence. Verified the way the ring was, by bit-identity one period apart. 20 periods is 43.00 s = exactly 172 film frames: frames N and N+172 differ by 0-1/255, while the control a quarter-second off (43.25 s) differs by 58.7/255. On the held boot title the glow-box mean swings 26.0 <-> 37.7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF --- authored/timing.json | 161 ++++++++++++++++++++++++------------ docs/port/DECISIONS.md | 63 ++++++++++++++ port/scripts/boot.gd | 26 ++++++ port/scripts/screen_view.gd | 37 ++++++++- 4 files changed, 233 insertions(+), 54 deletions(-) diff --git a/authored/timing.json b/authored/timing.json index 2ae18c5c..61851d16 100644 --- a/authored/timing.json +++ b/authored/timing.json @@ -1,59 +1,116 @@ { - "format": "sylpheed.timing/1", - "keyframe_units_per_second": 60, - "why": [ - "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", - "`t` is. The unit was MEASURED off the running game, not decoded: a declared", - "15-unit fade lands on round(255*k/15) for all seven of its samples with k", - "stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2", - "units per rendered frame -- and the idle title presents at 28.3-28.8 fps,", - "a 30 Hz game, giving 60 units per second. A second line agrees: the", - "transition quad is declared black for 12 units, and a capture measured the", - "pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.", - "", - "Expressed as units-per-second rather than seconds-per-unit so the value is", - "exact rather than a repeating decimal a reader has to recognise.", - "", - "DELETE THIS FILE when a field on the disc is found that states the unit.", - "Nothing here is on the disc." + "format": "sylpheed.timing/1", + "keyframe_units_per_second": 60, + "why": [ + "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", + "`t` is. The unit was MEASURED off the running game, not decoded: a declared", + "15-unit fade lands on round(255*k/15) for all seven of its samples with k", + "stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2", + "units per rendered frame -- and the idle title presents at 28.3-28.8 fps,", + "a 30 Hz game, giving 60 units per second. A second line agrees: the", + "transition quad is declared black for 12 units, and a capture measured the", + "pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.", + "", + "Expressed as units-per-second rather than seconds-per-unit so the value is", + "exact rather than a repeating decimal a reader has to recognise.", + "", + "DELETE THIS FILE when a field on the disc is found that states the unit.", + "Nothing here is on the disc." + ], + "kind": "measured", + "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", + "ramp": "linear", + "ramp_why": [ + "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", + "linear value at every one of the seven sampled frames, so there is no ease." + ], + "exit_ramp_seconds": 0.4, + "exit_ramp_why": [ + "HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.", + "", + "Every element of a screen ends on exactly ONE untimed keyframe, so there is", + "exactly one unknown duration per screen -- the ramp INTO that final keyframe.", + "This is that duration. ~0.4 s, which is 24 units at 60 units/s.", + "", + "The alternative readings were tested and refuted. It is not a black quad laid", + "over a frozen screen: under that model a black rect scales every region by the", + "same 1-alpha, so the button-region / background-region brightness RATIO would", + "be constant through the fade. Measured on the RE agent's filmstrip it falls", + "6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen", + "itself plays out: pteff00.prm ramps to opaque black while the button labels,", + "ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.", + "", + "REACH, quoted from the RE agent rather than smoothed over: the filmstrip is", + "downsampled and the button region contains some background, so this pins the", + "DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the", + "number as approximate and the model as established." + ], + "exit_ramp_units": 24, + "dwell_seconds": null, + "dwell_why": [ + "NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos", + "at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would", + "be inventing a number nobody measured, so the sequencer holds for zero extra", + "time and the pacing you see is the disc's own.", + "", + "If a capture ever times the real boot, this is where that number goes." + ], + "looping_focus_records": { + "_": [ + "WHICH focus records the port draws unconditionally, and loops. CHOSEN, and", + "deliberately a LOOKUP rather than a rule -- delete an entry the day the disc", + "or a capture states it.", + "", + "THE PRESS (A) PLATE GLOWS AND PULSES. Measured on the running game:", + "`ptbtn00f`, the plate's focus record, ramps alpha 0x00->0x50 and back, and", + "its period was timed four times at 2.12 / 2.19 / 2.34 / 2.31 s. You cannot", + "measure a period unless the thing repeats, so THAT IT LOOPS IS MEASURED. The", + "port drew nothing at all here, because press_start has no `buttons` and", + "nothing is focused.", + "", + "WHY A LOOKUP AND NOT A RULE, which is the part worth keeping. The spinning", + "ring is a RULE in the renderer (`spin_period_units`) because it has a", + "disc-wide check: 16 of 212 elements match its shape and all 16 are focus", + "rings, zero false positives. The obvious rule for a pulse -- a group whose", + "keyframes vary only in alpha and whose first and last alpha are equal -- was", + "CENSUSED BEFORE BEING WRITTEN and it fails: 82 OF 212 ELEMENTS MATCH,", + "including ptcopyright, palogo_sqex, ptmsg and every _eff fade-in. A renderer", + "rule on that shape would make the copyright notice pulse.", + "", + "Narrowing to focus records leaves exactly ONE distinct element (ptbtn00f,", + "plus its JP twin), and a rule justified by n=1 is a special case wearing a", + "rule's clothes. So it is written here, per element, by name." ], - "kind": "measured", - "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", - "ramp": "linear", - "ramp_why": [ - "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", - "linear value at every one of the seven sampled frames, so there is no ease." - ], - "exit_ramp_seconds": 0.4, - "exit_ramp_why": [ - "HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.", + "press_start/ptbtn00": { + "record_element": "ptbtn00f", + "period_units": 129, + "kind": "chosen", + "why": [ + "129 units = the element's own group under the port's existing timing model:", + "its last timed keyframe is t=105 and the final untimed keyframe is reached", + "`exit_ramp_units` (24) later. NO NEW CONSTANT is introduced -- 24 is the", + "same authored value every other element's exit already uses.", "", - "Every element of a screen ends on exactly ONE untimed keyframe, so there is", - "exactly one unknown duration per screen -- the ramp INTO that final keyframe.", - "This is that duration. ~0.4 s, which is 24 units at 60 units/s.", + "129 units is 2.150 s at 60 units/s, and 2.295 s at the ~28.1 fps that", + "emulator presents (56.2 units/s), against measurements of 2.12 / 2.19 /", + "2.34 / 2.31 s. It sits inside that spread at either rate.", "", - "The alternative readings were tested and refuted. It is not a black quad laid", - "over a frozen screen: under that model a black rect scales every region by the", - "same 1-alpha, so the button-region / background-region brightness RATIO would", - "be constant through the fade. Measured on the RE agent's filmstrip it falls", - "6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen", - "itself plays out: pteff00.prm ramps to opaque black while the button labels,", - "ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.", + "IT IS A CHOICE, and here is the alternative the data CANNOT rule out: the", + "cycle could restart at the group's first keyframe (t=6) rather than at 0,", + "giving 123 units = 2.050 s / 2.189 s at emulator rate. That ALSO sits", + "inside the measured spread. Nothing available separates them, so t=0 is", + "taken because it is where every other group in this port starts -- which is", + "consistency, not evidence.", "", - "REACH, quoted from the RE agent rather than smoothed over: the filmstrip is", - "downsampled and the button region contains some background, so this pins the", - "DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the", - "number as approximate and the model as established." - ], - "exit_ramp_units": 24, - "dwell_seconds": null, - "dwell_why": [ - "NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the", - "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos", - "at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would", - "be inventing a number nobody measured, so the sequencer holds for zero extra", - "time and the pacing you see is the disc's own.", + "A fifth reading exists and is NOT averaged in: the Decoder re-picked its", + "troughs and got 2.628 s, having previously reported 2.369 from the same", + "run. It withdrew the estimate as too weak to resolve better than ~7%, so it", + "is recorded here and not used.", "", - "If a capture ever times the real boot, this is where that number goes." - ] + "WHAT DELETES THIS ENTRY: a capture that times the pulse against a landmark", + "in the same frame, or a loop flag decoded on the disc." + ] + } + } } diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index d976c28a..6be3229f 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -3498,3 +3498,66 @@ the tool reporting its own resolution honestly rather than hiding it. **The oracle's numbers are quoted in the script as a test fixture and labelled as the oracle's**, with the RE document they come from. Nothing in the port derives them and nothing may. + +## The `PRESS Ⓐ` plate pulses — authored per element, because the census forbids a rule + +The human listed pulsation as first-class and the port drew nothing at all: the +plate's focus record `ptbtn00f` was never reached, because `press_start` has no +`buttons` and nothing is focused. + +**That it loops is measured**, not assumed. The corpus timed the pulse four times +— 2.12 / 2.19 / 2.34 / 2.31 s — and you cannot measure a period unless the thing +repeats. + +### The rule I was going to write, and the census that forbade it + +The spinning ring is a **rule** in the renderer (`spin_period_units`) and it +earns that: 16 of 212 elements match its shape and **all 16 are focus rings**, +zero false positives. So I looked for the analogous shape for a pulse — a group +whose keyframes vary **only** in alpha, whose first and last alpha are equal, a +closed cycle. `ptbtn00f` fits it exactly: `0 → 6 → 74 → 80 → 80 → 74 → 6 → 0`. + +**Censused before writing it: 82 of 212 elements match.** `ptcopyright`, +`palogo_sqex`, `ptmsg`, `ptlogo_back2`, and every `_eff` fade-in-hold-fade-out on +every screen. **A renderer rule on that shape would make the copyright notice +pulse.** + +Narrowing to focus records leaves exactly **one** distinct element (plus its JP +twin). A rule justified by n = 1 is a special case wearing a rule's clothes. So +the pulse is a **lookup** in `authored/timing.json`, keyed `/`, +and the census is recorded beside it so nobody widens it later. + +### The period is the element's own group — and the alternative is stated + +**129 units**: its last timed keyframe is t=105, and the final untimed keyframe +is reached `exit_ramp_units` (24) later. **No new constant** — 24 is the same +authored value every other element's exit already uses. That is 2.150 s at +60 units/s, or 2.295 s at the ~28.1 fps the emulator presents, against +measurements of 2.12–2.34 s. It sits inside the spread at either rate. + +⚠️ **It is a choice, and the alternative cannot be ruled out**: the cycle could +restart at the group's first keyframe (t=6) rather than at 0, giving 123 units = +2.050 / 2.189 s — **also inside the measured spread**. Nothing available +separates them. t=0 is taken because it is where every other group in this port +starts, which is consistency and not evidence, and `authored/timing.json` says so. + +A fifth reading is recorded and **not averaged in**: the Decoder re-picked its +troughs and got 2.628 s having previously reported 2.369 from the same run, then +withdrew the estimate as too weak to resolve better than ~7 %. + +### Verified the way the ring was — bit-identity one period apart + +20 authored periods is 2.15 × 20 = **43.00 s = exactly 172 film frames**, so +frames N and N+172 must be the same frame: + +| | max difference | +|---|---| +| f_055 vs f_227 (43.00 s) | **0/255** | +| f_060 vs f_232 (43.00 s) | **1/255** | +| f_070 vs f_242 (43.00 s) | **1/255** | +| f_079 vs f_251 (43.00 s) | **0/255** | +| **control** — f_070 vs f_243 (43.25 s) | **58.7/255** | + +The control is what makes the rest mean anything: a quarter-second off the period +differs by 58.7, on the period by 0–1. Measured on the held boot title, where the +glow-box mean swings **26.0 ↔ 37.7** — a real pulse, not a static glow. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 32af551e..44014d6f 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -178,8 +178,13 @@ func _ready() -> void: # The one unknown duration per screen: the ramp into the final untimed # keyframe. Authored, because the disc has no time slot there. view.exit_ramp_units = float(timing["exit_ramp_units"]) + # Which focus records draw unconditionally and loop. Kept out of ScreenView's + # own logic on purpose -- see `looping_focus` there for the census that says + # this cannot be a rule. + _looping = timing.get("looping_focus_records", {}) viewport.add_child(view) + view.looping_focus = _looping_for(name) if not view.load_screen(export_tree, name): push_error(export_tree.error) get_tree().quit(2) @@ -241,6 +246,8 @@ var _flow: Variant = null var _menu: MenuFlow = null var _play := false var _pending: Variant = null +## `authored/timing.json` `looping_focus_records`, keyed `/`. +var _looping: Dictionary = {} var _script: PackedStringArray = PackedStringArray() var _shots := "" ## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0. @@ -340,6 +347,7 @@ func _advance() -> void: print(" -> %s at %.2f s" % [name, _elapsed]) view.holding = true view.time_units = 0.0 + view.looping_focus = _looping_for(name) if not view.load_screen(view.tree, name): push_error(view.tree.error) get_tree().quit(2) @@ -541,6 +549,7 @@ func _menu_arrive() -> void: var name := String(action["goto"]) view.holding = true view.time_units = 0.0 + view.looping_focus = _looping_for(name) if not view.load_screen(view.tree, name): push_error(view.tree.error) get_tree().quit(2) @@ -854,6 +863,7 @@ func _raise_overlay(name: String) -> void: overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST overlay.units_per_second = view.units_per_second overlay.exit_ramp_units = view.exit_ramp_units + overlay.looping_focus = _looping_for(name) overlay.holding = true overlay.time_units = 0.0 if not overlay.load_screen(view.tree, name): @@ -925,3 +935,19 @@ func _finish_boot() -> void: var _capture_to := "" + + +## The authored looping-focus entries that apply to one screen. +## +## The table is keyed `/` so a reader can see at a glance which +## screen an entry belongs to -- `ptbtn00` exists on more than one build, and an +## entry that silently applied to all of them would be a rule again. +func _looping_for(screen_name: String) -> Dictionary: + var out := {} + for key: String in _looping.keys(): + if key == "_": + continue + var parts := key.split("/", true, 1) + if parts.size() == 2 and parts[0] == screen_name: + out[parts[1]] = _looping[key] + return out diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 9b966f60..60f42d98 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -42,6 +42,22 @@ var units_per_second: float = 60.0 ## that keyframe, so this is the one unknown duration per screen. var exit_ramp_units: float = 24.0 +## Focus records this screen draws unconditionally, and the period each loops on. +## +## `{ : { "record_element": String, "period_units": float } }`, +## from `authored/timing.json` `looping_focus_records`, keyed there by +## `/` and narrowed to this screen by `load_screen`. +## +## ⚠️ A LOOKUP, NOT A RULE, and the census is why. The spinning ring is a rule +## (`spin_period_units`) because 16 of 212 elements match its shape and all 16 +## are focus rings. The analogous rule for a pulse -- keyframes varying only in +## alpha, first alpha equal to last -- matches **82 of 212**, including +## `ptcopyright`, `palogo_sqex`, `ptmsg` and every `_eff` fade. It would make the +## copyright notice pulse. Narrowed to focus records it matches exactly one +## distinct element, and a rule justified by n=1 is a special case wearing a +## rule's clothes. +var looping_focus: Dictionary = {} + ## 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 @@ -353,6 +369,7 @@ static func _rot_of(pose: Dictionary) -> float: ## down-right and off-centre. func _draw_focus(element: Dictionary) -> void: var focus: Dictionary = element.get("focus", {}) + var parent_id := String(element.get("id", "")) for fe: Dictionary in focus.get("elements", []): var rel: String = fe.get("sprite", "") if rel == "": @@ -377,6 +394,19 @@ func _draw_focus(element: Dictionary) -> void: # and what the RE agent supplied is that one turn takes exactly that # long and repeats. See `spin_period_units` for the rule and its check. var pose: Dictionary = fe.get("rest", {}) + # An authored loop plays the record's OWN group on repeat instead of + # holding it at rest. `pose_at` already synthesises the final untimed + # keyframe at `exit_ramp_units`, so a loop is a modulo and nothing else -- + # no new machinery and no new constant. `holding` is bypassed for the + # same reason the ring bypasses it: a thing that pulses does not stop + # because the screen has arrived. + var loop: Dictionary = looping_focus.get(parent_id, {}) + if float(loop.get("period_units", 0.0)) > 0.0 \ + and String(loop.get("record_element", "")) == String(fe.get("id", "")): + var was := holding + holding = false + pose = pose_at(fe, fposmod(time_units, float(loop["period_units"]))) + holding = was var pivot := _vec(fe.get("pivot", [0, 0])) var pos := _vec(pose.get("pos", [0, 0])) var period := spin_period_units(fe) @@ -413,8 +443,11 @@ func _draw() -> void: 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"): + # 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 + # and focuses nothing. + if (focused_id == id or looping_focus.has(id)) and element.has("focus"): _draw_focus(element) continue var rel: String = element.get("sprite", "")