P5's gate is "a human clicks through it". The artifact is a scripted walk that
proves the wiring rather than the intent -- up (wraps 01->05), five down, (A)
into EXTRAS, down, (B) back, landing on the main menu with focus RESTORED to
EXTRAS, ten PNGs one per settled step:
xvfb-run -a godot --path port -- --menu \
--script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5
--script posts InputEventAction through Input.parse_input_event so the presses
arrive at _unhandled_input exactly as a d-pad's would. Calling MenuFlow directly
would have been shorter and would have proved nothing: the wiring between a
press and the cursor is the part most likely to be broken, and a direct call is
exactly the part that skips it.
Derived vs authored, which P5 is the easiest place to blur:
* DERIVED -- the ORDER of the items, from each screen file's `buttons`, which
the exporter already fills from button-role elements sorted by resting Y.
* AUTHORED -- destinations, initial focus, what (B) does, and left/right being
a no-op. All measured off the running game (HANDOFF Q4/Q5) or chosen, none
on the disc, all in authored/flow.json with a why.
Four of five main-menu destinations are `goto: null` with a `blocked` note. That
is a MILESTONE BOUNDARY, not an unknown -- DIFFICULTY, the save list, the lesson
list and OPTIONS were all measured and live in archives this export does not
carry. `blocked` and `none` are kept apart so nobody later "discovers" the gap.
--headless CANNOT DRAW, and the port hung instead of saying so.
Measured, not assumed: under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw, so every capture path awaited it forever --
--capture since P1, --film since P3, --shots as of now. With stdout block-
buffered the observable behaviour was SILENCE, FOREVER, which in a loop reads as
a job still working. Isolated by `--quit` (prints, exits 0) vs `--capture` (zero
bytes, killed at 40 s). Now those three flags refuse at STARTUP naming the
xvfb-run line that works, and --script no longer waits for a frame it is not
going to photograph -- so headless walks the menus in 4.5 s as a cheap
regression check needing no X server.
REFUTATION ATTEMPT, against the Decoder's 7eeae30 point 2 ("the oracle confirms
the game renders the ring's rotation"). Aimed there because PROTOCOL says to aim
at a claim the port is about to build on that rests on an estimator whose own
control the Decoder reported as +/-19.8 deg. IT SURVIVES, more strongly than
claimed.
Both captures draw the SAME sprite (ptbtneff01) 240 px apart, so "is it drawn
rotated" becomes "are these two crops one image at a different angle" -- no crop
offset needed and no reference to our own renderer. 360-bin angular luminance
profile over the annulus, circularly cross-correlated. Two controls first: known
rotations 0/30/90/150/210/270/330 recovered with 0 deg error, and a ring-free
patch of the same capture peaks at 0.369, so the estimator does not manufacture
matches. Then: A vs B 134 deg (corr 0.968), sprite vs A 76 deg, sprite vs B
210 deg -- and 210-76 = 134, which nothing in the method forced.
So 0 deg is NOT A POSE THE GAME SHOWS, and screen_view.gd draws the ring at
0 deg. That is now stated in the code as known-wrong rather than suspected. The
port did NOT start spinning it: the period has two unknowns and both are the
Decoder's -- the second keyframe is untimed, and "groups hold" predicts a stop
at 360 = 0 which contradicts both captures. Two frames of one focused button a
known time apart settle it. Filed in BLOCKED.md and asked over the channel.
BLOCKED.md's staleness check was half a check. It tested whether that page is
stale relative to HANDOFF; it cannot see the other direction, and the other
direction is what happened -- 7eeae30 lands 27 minutes AFTER HANDOFF was last
written and answers a question HANDOFF still lists as open. Added the missing
half: `git log --oneline 9ca1eb5..HEAD -- docs/re/`.
Also recorded, since the two were nearly confused: the ring's annulus centroid
lands within ~0.4 px of its design position under a ZERO crop offset, which
corroborates ORACLE-CAPTURES' "1279x675, top-left aligned" on a feature nobody
chose for the purpose. The earlier "text bands at design y + 23" is an offset
WITHIN the button sprite, not a crop offset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey
397 lines
16 KiB
GDScript
397 lines
16 KiB
GDScript
# Draws one exported screen, either at a moment on its timeline or at the
|
|
# `rest` pose the export declares.
|
|
#
|
|
# TIMELINE is the real behaviour and the default. A keyframe is the start of a
|
|
# LINEAR ramp toward the next, and the unit of `t` comes from
|
|
# `authored/timing.json` -- it is measured, not on the disc, which is why it is
|
|
# authored and applied in exactly one place.
|
|
#
|
|
# REST reproduces what the export's `rest` field says, which is what
|
|
# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can
|
|
# hold both renderers to the same assumption. The two modes DISAGREE on six
|
|
# elements in this export, and the running game sides with the timeline -- see
|
|
# `docs/DECISIONS.md`.
|
|
#
|
|
# One CanvasItem draws the whole screen in `_draw`, rather than a node per
|
|
# element. The export's `paint_order` is already back-to-front, so honouring it
|
|
# is a loop; z-indexing sixteen nodes to reproduce the same order would be the
|
|
# same information expressed less directly, and would hide a tie behind Godot's
|
|
# own sibling rules.
|
|
class_name ScreenView
|
|
extends Node2D
|
|
|
|
## Skip `kind & 0x4` template instances that duplicate a plain element.
|
|
## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest.
|
|
## The narrow form of the rule matters -- 174 elements on the disc carry the bit
|
|
## with no template to duplicate, and a blanket skip would erase them.
|
|
const KIND_TEMPLATE_INSTANCE := 0x4
|
|
|
|
enum Pose { TIMELINE, REST }
|
|
|
|
## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws
|
|
## the export's declared `rest` and is there for renderer-vs-renderer diffing.
|
|
var pose_mode: Pose = Pose.TIMELINE
|
|
|
|
## Position on the timeline, in the disc's own keyframe units. `t` is left raw
|
|
## everywhere; seconds appear only where `units_per_second` is applied.
|
|
var time_units: float = 0.0
|
|
var units_per_second: float = 60.0
|
|
|
|
## Duration of the ramp into the final, untimed keyframe -- the screen playing
|
|
## itself out. Authored (`authored/timing.json`): the disc has no time slot on
|
|
## that keyframe, so this is the one unknown duration per screen.
|
|
var exit_ramp_units: float = 24.0
|
|
|
|
## 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
|
|
|
|
var tree: ExportTree = null
|
|
var screen: Dictionary = {}
|
|
var textures: Dictionary = {}
|
|
var skipped: Array[String] = []
|
|
var drawn: Array[String] = []
|
|
|
|
## Which button is highlighted, by element id. P1 leaves it empty: initial focus
|
|
## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an
|
|
## authored decision that belongs to P5.
|
|
var focused_id: String = ""
|
|
|
|
|
|
func load_screen(t: ExportTree, name: String) -> bool:
|
|
tree = t
|
|
screen = t.screen(name)
|
|
if screen.is_empty():
|
|
push_error(t.error)
|
|
return false
|
|
var design: Array = screen.get("design", [1280, 720])
|
|
# The export's coordinates are in this space and the viewport matches it, so
|
|
# a mismatch means the export is not what this project was built to draw.
|
|
var viewport := Vector2i(
|
|
ProjectSettings.get_setting("display/window/size/viewport_width"),
|
|
ProjectSettings.get_setting("display/window/size/viewport_height"))
|
|
if Vector2i(int(design[0]), int(design[1])) != viewport:
|
|
push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport])
|
|
_load_textures()
|
|
queue_redraw()
|
|
return true
|
|
|
|
|
|
func _load_textures() -> void:
|
|
textures.clear()
|
|
for element: Dictionary in screen.get("elements", []):
|
|
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:
|
|
push_warning(tree.error)
|
|
else:
|
|
textures[rel] = tex
|
|
|
|
|
|
# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on
|
|
# purpose, because the disc spells them differently and a silent swap looks like
|
|
# an art bug rather than a parse bug. They multiply per channel.
|
|
static func modulate_of(pose: Dictionary) -> Color:
|
|
var tint := _rgba(pose.get("tint_rgba", "0xffffffff"))
|
|
var fade := _argb(pose.get("fade_argb", "0xffffffff"))
|
|
return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a)
|
|
|
|
|
|
static func _rgba(hex: String) -> Color:
|
|
var v := hex.hex_to_int()
|
|
return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff)
|
|
|
|
|
|
static func _argb(hex: String) -> Color:
|
|
var v := hex.hex_to_int()
|
|
return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff)
|
|
|
|
|
|
## The drawn rectangle of an element at a pose.
|
|
##
|
|
## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so
|
|
## the top-left moves by `-pivot*(s-1)` and the size is the natural size times
|
|
## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly.
|
|
static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2:
|
|
var pos := _vec(pose.get("pos", [0, 0]))
|
|
var s := _vec(pose.get("scale", [100, 100])) / 100.0
|
|
return Rect2(pos - pivot * (s - Vector2.ONE), natural * s)
|
|
|
|
|
|
static func _vec(a: Array) -> Vector2:
|
|
return Vector2(float(a[0]), float(a[1]))
|
|
|
|
|
|
## The pose of one element at `time_units`.
|
|
##
|
|
## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a
|
|
## screen that has arrived sits on the **hold**. So the timeline plays in and
|
|
## stops at `rest`, which is the decoders' identification of that hold and
|
|
## carries its own `t`.
|
|
##
|
|
## It is emphatically NOT "play to the last timed keyframe". The exit is not
|
|
## only the final untimed frame -- it can be a long run of TIMED ones. The
|
|
## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and
|
|
## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the
|
|
## whole screen ~13/255 too bright. That was measured against a plate-free
|
|
## capture of the running title, and it is what corrected this rule.
|
|
##
|
|
## Before the first keyframe the element holds its first pose -- the pre-roll a
|
|
## staggered menu needs, with the five buttons starting at t=28,30,32,34,36.
|
|
func pose_at(element: Dictionary, t: float) -> Dictionary:
|
|
var frames: Array = element.get("keyframes", [])
|
|
var timed: Array = []
|
|
for k: Dictionary in frames:
|
|
if k.has("t"):
|
|
timed.append(k)
|
|
if timed.is_empty():
|
|
# No timed frame at all: the group is a single static pose.
|
|
return frames[0] if not frames.is_empty() else element.get("rest", {})
|
|
# While holding, stop at the hold: past it the group is ramping out, and a
|
|
# screen that has arrived and is sitting there is not leaving.
|
|
if holding:
|
|
t = minf(t, settle_units(element))
|
|
# The exit. The final keyframe carries no `t` -- the disc has no slot for one
|
|
# -- so it is given a synthetic time `exit_ramp_units` after the last timed
|
|
# frame and then interpolated like any other. That keeps one code path: the
|
|
# difference between arriving and leaving is only how far `t` is allowed to
|
|
# run, not a second kind of animation.
|
|
#
|
|
# The whole group plays out, not just the fade quad: on the main menu
|
|
# pteff00 ramps to opaque black while the labels ramp to transparent and
|
|
# ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen
|
|
# was measured and refuted -- see authored/timing.json.
|
|
var last_frame: Dictionary = frames[frames.size() - 1]
|
|
if not last_frame.has("t"):
|
|
var exit_frame := last_frame.duplicate()
|
|
exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units
|
|
timed.append(exit_frame)
|
|
|
|
if t <= float(timed[0]["t"]):
|
|
return timed[0]
|
|
for i in range(timed.size() - 1):
|
|
var a: Dictionary = timed[i]
|
|
var b: Dictionary = timed[i + 1]
|
|
var t0 := float(a["t"])
|
|
var t1 := float(b["t"])
|
|
if t < t1:
|
|
# A keyframe is the start of a ramp toward the next, and the ramp is
|
|
# linear -- measured, `authored/timing.json`.
|
|
return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0))
|
|
return timed[timed.size() - 1]
|
|
|
|
|
|
# Channels are integers on the disc. The running game's own fade lands on
|
|
# `round(255*k/15)`, so rounding -- not truncation -- is what was measured.
|
|
static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
|
|
return {
|
|
"pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)],
|
|
"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),
|
|
}
|
|
|
|
|
|
static func _ilerp(a: float, b: float, f: float) -> int:
|
|
return int(round(a + (b - a) * f))
|
|
|
|
|
|
# Byte-wise, so it works for both orders without knowing which one it has.
|
|
static func _hex_lerp(a: String, b: String, f: float) -> String:
|
|
var x := a.hex_to_int()
|
|
var y := b.hex_to_int()
|
|
var out := 0
|
|
for shift in [24, 16, 8, 0]:
|
|
out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift
|
|
return "0x%08x" % out
|
|
|
|
|
|
## Where one element stops, in keyframe units: its hold.
|
|
##
|
|
## `rest.t` when the export gives one. An element whose `rest` carries no time is
|
|
## a single static pose, and there the last timed keyframe is the same answer.
|
|
static func settle_units(element: Dictionary) -> float:
|
|
var rest: Dictionary = element.get("rest", {})
|
|
if rest.has("t"):
|
|
return float(rest["t"])
|
|
var last := 0.0
|
|
for k: Dictionary in element.get("keyframes", []):
|
|
if k.has("t"):
|
|
last = maxf(last, float(k["t"]))
|
|
return last
|
|
|
|
|
|
## The moment the whole screen has arrived: the last element to reach its hold.
|
|
func settle_time() -> float:
|
|
var last := 0.0
|
|
for element: Dictionary in screen.get("elements", []):
|
|
last = maxf(last, settle_units(element))
|
|
return last
|
|
|
|
|
|
## The moment the screen has finished playing itself out, in keyframe units --
|
|
## the last element's final timed keyframe plus the authored exit ramp.
|
|
func exit_time() -> float:
|
|
var last := 0.0
|
|
for element: Dictionary in screen.get("elements", []):
|
|
var frames: Array = element.get("keyframes", [])
|
|
if frames.is_empty():
|
|
continue
|
|
var timed_end := 0.0
|
|
for k: Dictionary in frames:
|
|
if k.has("t"):
|
|
timed_end = maxf(timed_end, float(k["t"]))
|
|
if not frames[frames.size() - 1].has("t"):
|
|
timed_end += exit_ramp_units
|
|
last = maxf(last, timed_end)
|
|
return last
|
|
|
|
|
|
# An element is a ghost only when another element on the same screen carries the
|
|
# same id *without* the template bit -- the template it is a repeat of.
|
|
func _template_instance_ids() -> Dictionary:
|
|
var plain := {}
|
|
for element: Dictionary in screen.get("elements", []):
|
|
if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0:
|
|
plain[element.get("id", "")] = true
|
|
var ghosts := {}
|
|
for element: Dictionary in screen.get("elements", []):
|
|
var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int())
|
|
if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")):
|
|
ghosts[int(element.get("index", -1))] = true
|
|
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, which is rotation_deg 0.
|
|
#
|
|
# ⚠️ THIS IS KNOWN TO BE WRONG, and is drawn anyway because the right
|
|
# answer is a guess. The spin is real -- rotation_deg ramps 0 -> 360
|
|
# with position, scale and alpha all constant -- and measuring the two
|
|
# oracle captures says the game never shows 0: the same sprite sits at
|
|
# ~76 deg with NEW GAME focused and ~210 deg with OPTIONS focused,
|
|
# 134 deg apart at peak correlation 0.97 against a null control of 0.37
|
|
# (docs/port/DECISIONS.md, "the focus ring IS drawn rotated").
|
|
#
|
|
# What is missing is the PERIOD, and it has two unknowns, both the
|
|
# Decoder's: the ramp's second keyframe is untimed, and "groups hold"
|
|
# predicts a stop at 360 = 0, which is not what either capture shows.
|
|
# Holding at 0 is the pose that invents nothing; a spin rate would be
|
|
# invented. See docs/port/BLOCKED.md.
|
|
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
|
|
var elements: Array = screen.get("elements", [])
|
|
var ghosts := _template_instance_ids()
|
|
skipped.clear()
|
|
drawn.clear()
|
|
for index: int in screen.get("paint_order", []):
|
|
var element: Dictionary = elements[index]
|
|
var id: String = element.get("id", "")
|
|
if ghosts.has(index):
|
|
skipped.append("%s (template instance)" % id)
|
|
continue
|
|
var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \
|
|
else pose_at(element, time_units)
|
|
var colour := modulate_of(pose)
|
|
if colour.a <= 0.0:
|
|
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"]
|
|
if rel != "":
|
|
var tex: Texture2D = textures.get(rel)
|
|
if tex == null:
|
|
skipped.append("%s (sprite failed to load)" % id)
|
|
continue
|
|
_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_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
|
|
# so is the point -- a silently missing element looks like art.
|
|
skipped.append("%s (no sprite in the export)" % id)
|