Files
Sylpheed/port/scripts/boot.gd
Sylpheed port agent eef45ecfd6 port: P5 -- the menus navigate, and the focus ring is drawn wrong on purpose
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
2026-08-29 11:27:05 +00:00

534 lines
20 KiB
GDScript

# Entry point.
#
# P1 shows one exported screen, statically, so that its pixels can be diffed
# against `sylpheed-cli screen render` of the same build. The boot sequence
# proper (splash -> intro -> title -> menu) is P3 and is not here.
#
# godot --path port -- --screen=main_menu
# godot --path port -- --screen=main_menu --capture=/tmp/godot.png
# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
# godot --path port -- --boot # the whole boot sequence
# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s
# godot --path port -- --menu # P5: navigate the menus
# godot --path port -- --menu=extras # ...starting somewhere else
# godot --path port -- --boot --play # boot, then hand over to P5
# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5
#
# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back.
# `--script` drives the SAME input path with synthetic events -- it does not call
# the navigation functions directly, because then the artifact would prove
# nothing about whether a human's press arrives. `--shots` writes one PNG per
# scripted step, after the screen it produced has settled.
#
# `--time` is in SECONDS and freezes the timeline there; without it the screen
# animates in real time from t=0. `--pose=rest` draws the export's declared
# resting pose instead of the timeline -- what the reference renderer draws, so
# that a renderer-vs-renderer diff compares like with like.
#
# The screen is drawn into a SubViewport sized to the export's own `design`
# rectangle and shown through a container that scales it to the window. That is
# the same separation the project settings already make -- design space is
# fixed, the window is not -- and it makes `--capture` exact: the PNG is the
# design rectangle itself, never the window, so it is directly comparable with
# `screen render`'s composite with no cropping or rescaling.
extends Node
const DEFAULT_SCREEN := "main_menu"
var view: ScreenView = null
var viewport: SubViewport = null
func _ready() -> void:
var args := _args()
for flag: String in ["capture", "film", "shots"]:
if args.has(flag) and not _has_display(flag):
get_tree().quit(4)
return
var export_tree := ExportTree.locate()
if export_tree.root == "":
push_error(export_tree.error)
get_tree().quit(2)
return
_flow = export_tree.authored("flow.json")
if _flow == null and (args.has("boot") or args.has("menu")):
push_error(export_tree.error)
get_tree().quit(2)
return
if args.has("boot"):
for step: Dictionary in _flow["boot"]:
_sequence.append(step)
_film = args.get("film", "")
_shots = args.get("shots", "")
if args.has("script"):
_script = args["script"].split(",", false)
# P5. `--play` boots first and hands over on the title; `--menu` starts on a
# screen directly, which is what makes an unattended run cheap -- it does not
# sit through 137 s of intro to press a d-pad.
_play = args.has("play") or args.has("menu")
if _play:
_menu = MenuFlow.new()
if not _menu.configure(_flow):
push_error(_menu.error)
get_tree().quit(2)
return
var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \
else args.get("menu", args.get("screen", DEFAULT_SCREEN))
if name == "1":
name = DEFAULT_SCREEN # bare `--menu`
if name == "":
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
var screen: Dictionary = export_tree.screen(name)
if screen.is_empty():
push_error(export_tree.error)
print("screens in this export: ", ", ".join(export_tree.screen_names()))
get_tree().quit(2)
return
var design: Array = screen.get("design", [1280, 720])
var container := SubViewportContainer.new()
container.stretch = true
container.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(container)
viewport = SubViewport.new()
viewport.size = Vector2i(int(design[0]), int(design[1]))
viewport.transparent_bg = false
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
container.add_child(viewport)
view = ScreenView.new()
# The export is a 1:1 copy of the disc's texels and elements are drawn at up
# to 500 %. Nearest is also what the reference renderer does
# (`ui_layout::blit` maps destination to source by integer division), so a
# filter difference cannot masquerade as a placement difference in the diff.
view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
view.focused_id = args.get("focus", "")
if args.get("pose", "") == "rest":
view.pose_mode = ScreenView.Pose.REST
# The keyframe unit is MEASURED, not on the disc, so it is authored and read
# in exactly one place -- here.
var timing: Variant = export_tree.authored("timing.json")
if timing == null:
push_error(export_tree.error)
get_tree().quit(2)
return
view.units_per_second = float(timing["keyframe_units_per_second"])
# 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"])
viewport.add_child(view)
if not view.load_screen(export_tree, name):
push_error(export_tree.error)
get_tree().quit(2)
return
# Not booting: `--menu` opens straight onto a screen, so the stack starts here.
if _menu != null and _sequence.is_empty():
_menu_enter(name, true)
var settle := view.settle_time()
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [
name, view.screen["elements"].size(), view.screen["paint_order"].size(),
design[0], design[1], settle, settle / view.units_per_second])
if args.has("time"):
_frozen = true
view.time_units = float(args["time"]) * view.units_per_second
view.queue_redraw()
if _film != "":
set_process(true)
_film_capture()
if args.has("capture"):
await _capture(args["capture"])
get_tree().quit(0)
var _frozen := false
var _flow: Variant = null
var _menu: MenuFlow = null
var _play := false
var _pending: Variant = null
var _script: PackedStringArray = PackedStringArray()
var _shots := ""
var _script_started := false
var _sequence: Array[Dictionary] = []
var _player: VideoStreamPlayer = null
var _step := 0
var _film := ""
var _film_frame := 0
var _film_next := 0.0
var _elapsed := 0.0
var _boot_done := false
func _process(delta: float) -> void:
if _frozen or view == null:
return
view.time_units += delta * view.units_per_second
_elapsed += delta
view.queue_redraw()
if _player != null:
return
# A menu transition. This is checked BEFORE the boot sequence and outside
# its emptiness guard: `--menu` has no sequence at all, and an earlier
# version returned here, so the screen faded out and nothing ever arrived.
if _pending != null:
if view.time_units >= view.exit_time():
_menu_arrive()
return
if _sequence.is_empty():
return
# A screen holds at `rest` until it has arrived, then plays itself out and
# the next one begins. Nothing waits on a timer the disc does not carry: the
# pacing is each group's own timeline (authored/flow.json, `dwell`).
if view.holding and view.time_units >= view.settle_time():
# The LAST screen in the sequence keeps holding. A screen plays itself
# out because something is taking its place; nothing is taking the
# title's place here, and a boot that ends by fading to black is a boot
# that looks like it crashed. P4 puts the intro video in front of the
# title, and P5 gives the title somewhere to go.
if _step + 1 < _sequence.size():
view.holding = false
elif not _boot_done:
_boot_done = true
print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]])
# P5 takes over here: the boot ends on the title and the title has
# somewhere to go. Without `--play` the run still stops, because a
# boot that ends by waiting for a key it will never get is worse
# than one that exits.
if _play:
_menu_enter(String(_sequence[_step].get("screen", "")), true)
elif _film == "":
get_tree().quit(0)
elif not view.holding and view.time_units >= view.exit_time():
_advance()
func _advance() -> void:
_step += 1
var next: Dictionary = _sequence[_step]
if next.has("video"):
_play_video(String(next["video"]), bool(next.get("skippable", false)))
return
var name := String(next["screen"])
print(" -> %s at %.2f s" % [name, _elapsed])
view.holding = true
view.time_units = 0.0
if not view.load_screen(view.tree, name):
push_error(view.tree.error)
get_tree().quit(2)
## Play one transcoded movie, full-bleed over the screen.
##
## The port never reads WMV: the exporter transcoded this to Ogg Theora and
## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder
## who dislikes the quality re-runs one line.
func _play_video(name: String, skippable: bool) -> void:
var v := view.tree.video(name)
if v.is_empty():
push_error(view.tree.error)
get_tree().quit(2)
return
print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]])
var stream := VideoStreamTheora.new()
stream.file = v["path"]
_player = VideoStreamPlayer.new()
_player.stream = stream
_player.expand = true
_player.set_anchors_preset(Control.PRESET_FULL_RECT)
# Into the SubViewport, not beside it. Everything this port draws composes in
# the export's own 1280x720 design space; a player parented to the Boot node
# renders to the window instead and is invisible to `--capture`, which reads
# the SubViewport. That is not only a capture artefact -- it would also put
# the movie outside the space every screen coordinate is expressed in.
viewport.add_child(_player)
_skippable = skippable
# `play()` needs the node in the tree; calling it before that is an error
# the engine reports and then ignores, which looks like a video that simply
# never starts.
await get_tree().process_frame
_player.finished.connect(_video_finished)
_player.play()
var _skippable := false
func _video_finished() -> void:
print(" video ended at %.2f s" % _elapsed)
_player.queue_free()
_player = null
_advance()
func _unhandled_input(event: InputEvent) -> void:
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
# at 57 s against a 193 s baseline.
if _player != null:
if _skippable and (event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel")):
print(" video skipped at %.2f s" % _elapsed)
_player.stop()
_video_finished()
return
if _menu == null or _menu.stack.is_empty():
return
# AUTHORED, not measured: a press during a screen's fade-out is dropped.
# `authored/flow.json` says why -- nobody has watched what the game does
# here, and dropping invents less than queueing.
if _pending != null:
return
var buttons: Array = view.screen.get("buttons", [])
if event.is_action_pressed("ui_up"):
_menu_move(-1, buttons)
elif event.is_action_pressed("ui_down"):
_menu_move(1, buttons)
elif event.is_action_pressed("ui_left") or event.is_action_pressed("ui_right"):
# MEASURED, HANDOFF Q5: left/right do nothing. Written out rather than
# left unhandled so that "the game ignores it" and "we never wired it"
# are different lines of code.
pass
elif event.is_action_pressed("ui_accept"):
_menu_activate(_menu.accept(buttons))
elif event.is_action_pressed("ui_cancel"):
_menu_activate(_menu.cancel())
func _menu_move(step: int, buttons: Array) -> void:
if _menu.move(step, buttons):
view.focused_id = _menu.focus()
view.queue_redraw()
print(" focus -> %s" % view.focused_id)
## Act on what the flow returned. A destination starts the screen playing itself
## out; the arrival happens in `_process` when the exit ramp is done, so the
## fade is the transition HANDOFF Q7 measured and not a cut.
func _menu_activate(action: Dictionary) -> void:
match String(action.get("kind", "none")):
"enter":
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
_pending = action
view.holding = false
"blocked":
# A real, measured destination that is not in this export. Say which
# -- silence here would read as a dead button.
print(" (%s) opens a screen this export does not carry: %s"
% [action.get("label", ""), action.get("why", "")])
_:
pass
## Enter a screen with the menu live. `fresh` seeds the stack rather than
## replacing the top, which is what a boot handover and `--menu` both want.
func _menu_enter(name: String, fresh: bool) -> void:
if name == "" or not _menu.known(name):
push_warning("flow.json describes no screen named %s -- navigation stops here" % name)
return
if fresh:
_menu.enter(name, view.screen.get("buttons", []))
view.focused_id = _menu.focus()
view.queue_redraw()
print(" menu on %s, focus %s" % [name, view.focused_id])
if not _script.is_empty() and not _script_started:
_script_started = true
_run_script()
## The moment a screen has finished fading out and the next one takes over.
func _menu_arrive() -> void:
var action: Dictionary = _pending
_pending = null
var name := String(action["goto"])
view.holding = true
view.time_units = 0.0
if not view.load_screen(view.tree, name):
push_error(view.tree.error)
get_tree().quit(2)
return
var buttons: Array = view.screen.get("buttons", [])
if action.get("pop", false):
# MEASURED, HANDOFF Q5: (B) restores the focus you came from.
_menu.pop()
_menu.stack[_menu.stack.size() - 1]["focus"] = String(action["restore_focus"])
view.focused_id = _menu.focus()
view.queue_redraw()
print(" menu on %s, focus restored to %s" % [name, view.focused_id])
else:
_menu_enter(name, true)
## Whether this process can produce a picture at all.
##
## MEASURED here, not assumed: under `--headless` Godot's dummy renderer never
## emits `RenderingServer.frame_post_draw`, so every `await` on it blocks
## forever. `godot-headless --path port -- --screen=main_menu --capture=…`
## therefore hung with NO OUTPUT until it was killed -- the same run with
## `--quit` prints and exits, which is how the difference was isolated.
##
## That is the worst shape a failure can take in an unattended loop: it does not
## fail, it waits, and a job that waits forever reads as a job still working.
## So the flags that need a frame refuse at STARTUP and say what to run instead,
## rather than dying somewhere in the middle of a filmstrip.
func _has_display(flag: String) -> bool:
if DisplayServer.get_name() != "headless":
return true
push_error(("--%s needs a drawn frame, and --headless never draws one: " +
"Godot's dummy renderer does not emit frame_post_draw, so this would " +
"hang rather than fail. Run it under Xvfb instead:\n" +
" xvfb-run -a godot --path port -- …--%s=…") % [flag, flag])
return false
func _capture(path: String) -> void:
# Two frames: the first is the one this callback is still inside of.
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img := viewport.get_texture().get_image()
print("t = %.2f units (%.3f s), pose = %s" % [
view.time_units, view.time_units / view.units_per_second,
"rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"])
print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)])
if not view.skipped.is_empty():
print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)])
var err := img.save_png(path)
if err != OK:
push_error("cannot write %s (%d)" % [path, err])
return
print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path])
## A frame every 0.25 s for the whole run, so an unattended boot leaves a
## filmstrip behind rather than requiring someone to be watching it.
func _film_capture() -> void:
while true:
await RenderingServer.frame_post_draw
if _elapsed >= _film_next:
var img := viewport.get_texture().get_image()
img.save_png("%s_%03d.png" % [_film, _film_frame])
_film_frame += 1
_film_next += 0.25
# Godot passes everything after `--` through untouched; take `--key=value`.
static func _args() -> Dictionary:
var out := {}
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--") and arg.contains("="):
var pair := arg.substr(2).split("=", true, 1)
out[pair[0]] = pair[1]
elif arg.begins_with("--"):
out[arg.substr(2)] = "1"
return out
# ── The scripted walk ───────────────────────────────────────────────────────
#
# `--script=down,down,accept,cancel` presses those buttons in order and, with
# `--shots=`, leaves one PNG per step behind. This is the P5 artifact for an
# unattended run.
#
# It sends synthetic events through `Input.parse_input_event`, so they arrive at
# `_unhandled_input` exactly as a d-pad's would. Calling the navigation
# functions directly would have been three lines shorter and would have proved
# nothing: the thing most likely to be broken is the wiring between a press and
# the cursor, and that is the part a direct call skips.
const SCRIPT_ACTIONS := {
"up": "ui_up", "down": "ui_down", "left": "ui_left", "right": "ui_right",
"accept": "ui_accept", "a": "ui_accept", "cancel": "ui_cancel", "b": "ui_cancel",
}
## How long a single step may take before the run is called stuck, in seconds.
## A screen that never settles would otherwise hang an unattended job forever;
## the title's own timeline is 4.5 s, so this is generous rather than tuned.
const SCRIPT_STEP_TIMEOUT := 20.0
func _run_script() -> void:
if not await _script_settled("start"):
return
await _shoot("00_start")
for i in range(_script.size()):
var token := _script[i].strip_edges().to_lower()
if token == "wait":
pass
elif SCRIPT_ACTIONS.has(token):
print("script[%d] %s" % [i + 1, token])
_press(String(SCRIPT_ACTIONS[token]))
else:
push_error("--script: no such step %s (have %s, wait)" % [token, ", ".join(SCRIPT_ACTIONS.keys())])
get_tree().quit(2)
return
# `Input.parse_input_event` is flushed with the frame, not on the call.
# Without these two frames the settle check runs while the press has not
# been delivered yet, decides nothing is moving, and photographs the
# screen the press was about to leave.
await get_tree().process_frame
await get_tree().process_frame
if not await _script_settled(token):
return
await _shoot("%02d_%s" % [i + 1, token])
print("script complete after %.2f s on %s, focus %s"
% [_elapsed, _menu.current(), view.focused_id])
get_tree().quit(0)
func _press(action: String) -> void:
for down in [true, false]:
var e := InputEventAction.new()
e.action = action
e.pressed = down
Input.parse_input_event(e)
## Wait until nothing is moving: no transition pending, and the screen has
## reached its own hold. Shooting before that would photograph a fade.
func _script_settled(what: String) -> bool:
var deadline := _elapsed + SCRIPT_STEP_TIMEOUT
while _pending != null or _player != null or not view.holding \
or view.time_units < view.settle_time():
if _elapsed > deadline:
# Stop the run. Carrying on would write a whole filmstrip of the
# screen that got stuck and call it a walk through the menus.
push_error("--script: %s never settled within %.0f s -- stopping"
% [what, SCRIPT_STEP_TIMEOUT])
get_tree().quit(3)
return false
await get_tree().process_frame
# Only a run that is about to photograph the frame needs to wait for one to
# be drawn. `--script` on its own is a navigation check and must still work
# where nothing draws -- see `_has_display`.
if _shots != "":
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
return true
func _shoot(label: String) -> void:
if _shots == "":
return
var path := "%s_%s.png" % [_shots, label]
var img := viewport.get_texture().get_image()
# Write to a temp name and rename on completion: another agent probing a
# file this is still writing gets a confident wrong number.
var tmp := path + ".part"
if img.save_png(tmp) != OK:
push_error("cannot write %s" % tmp)
return
DirAccess.rename_absolute(tmp, path)
print(" shot %s (%s, focus %s)" % [path, _menu.current(), view.focused_id])