F6 is a clock question, not an effect question: the human confirms the light animation itself looks like the game's and only starts earlier, and separately that the port may be running it faster (flagged by them as an impression). The port passed time_units straight to the leaf, which is an assumption -- offset 0, rate 1 -- that nobody measured and that the Decoder's capture contradicts: the game's leaf t=0 is its first drawn frame, 40 frames after the title's first element, at a rate measuring well below the title's. ScreenView.leaf_clock(screen_units) applies an origin and a rate; both default to -1.0 meaning unmeasured, in which case it is the identity and behaviour is unchanged. Fed from authored/rendering.json leaf_clock per screen, currently null with the provenance recorded. No placeholder values, per F1: an invented constant here is indistinguishable from a measured one later. Verified inert: title rendered at t=2/3/4 s, 0 differing pixels against captures taken before the change. Also corrected in the process: my earlier 'the sweep enters the viewport at t=61' used the UNROTATED sprite width. The leaf carries a 30 deg rest rotation, so its AABB is 886 px against a 399 px sprite -- matching the Decoder's measured ~890 to within 4 px. Port and game both put the quad on screen at leaf t~0, so the whole discrepancy is the leaf clock's origin and rate.
1818 lines
86 KiB
GDScript
1818 lines
86 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=title --no-hold
|
|
# # play PAST the rest instead of clamping
|
|
# # each element at its own hold.
|
|
# # ⚠️ NOT with `--time`: that sets `frozen`,
|
|
# # and `pose_at` tests `holding and not
|
|
# # frozen`, so an explicit instant makes
|
|
# # `--no-hold` a no-op. Verified: identical
|
|
# # renders with `--time`, max 253 different
|
|
# # without it.
|
|
# godot --path port -- --boot --film=/tmp/b --film-interval=0.1
|
|
# # frame cadence for --film (default 0.25)
|
|
# godot --path port -- --boot --skip-at=1 # press (A) at 1 s to skip a movie
|
|
#
|
|
# 🔴 THESE THREE WERE LIVE AND UNDOCUMENTED. `--no-hold` is documented in
|
|
# DECISIONS.md and was absent from the block a reader actually consults;
|
|
# `--film-interval` and `--skip-at` are used by `tools/port/verify-dwell` and
|
|
# appeared nowhere else. A capability that exists only in the record is, to
|
|
# anyone reading the interface, a capability that does not exist.
|
|
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
|
|
# godot --path port -- --screen=title --overlay=press_start --time=4
|
|
# godot --path port -- --boot # the whole boot sequence
|
|
# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s
|
|
# # ⚠️ RUNS UNTIL KILLED. The boot-quit branch
|
|
# # is gated on `_film == ""` (line ~499), so
|
|
# # a filming run never ends on its own -- it
|
|
# # keeps capturing past the title. Measured:
|
|
# # title reached at 7.8 s with --skip-at=1,
|
|
# # still filming at 300 s, 375 frames.
|
|
# # `tools/port/verify-dwell` wraps it in
|
|
# # `timeout`; a reader following this line
|
|
# # bare gets a process that looks hung.
|
|
# 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
|
|
# godot --path port -- --menu --script=down,accept --audio=/tmp/p6.wav
|
|
#
|
|
# `--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.
|
|
#
|
|
# `--audio=` records the MASTER BUS to a WAV for the whole run. Neither container
|
|
# has a sound card, so "does it actually play?" cannot be answered by listening --
|
|
# but it can be answered by measurement, and an `AudioEffectRecord` on Master
|
|
# captures the mixed output from inside a headless run with no device at all.
|
|
# `docs/port/AUDIO-VERIFICATION.md` §2. The run PRINTS the audio driver it used,
|
|
# because "recorded under a dummy driver" is a weaker claim than "heard" and the
|
|
# write-up has to be able to say which one it is making.
|
|
#
|
|
# `--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
|
|
var audio: MenuAudio = null
|
|
|
|
## The second build, drawn OVER `view`. The boot title is the only place in this
|
|
## port where two builds are on screen at once (`authored/flow.json`, the boot's
|
|
## `title` step): build 4 presents alone and the `PRESS Ⓐ BUTTON` plate -- build
|
|
## 2 -- arrives later.
|
|
##
|
|
## **They share one clock, started together, and there is no authored delay.**
|
|
## The plate reaches full alpha at its own declared `t = 236`; build 4's effect
|
|
## quads end their ramps together at `t = 118`; the 118-unit difference is
|
|
## 1.967 s, against an oracle that measured 2.138 s and 2.132 s at an emulator
|
|
## presenting 28.1 fps rather than 30.
|
|
##
|
|
## 🔴 THIS SAID `t = 238` AND `120 units = 2.000 s`, AND BOTH ARE OFF BY TWO.
|
|
## `ptbtn00`'s declared alpha reaches `0xff` at **t = 236** and *holds* it to
|
|
## t = 238, then ramps back to 0 by t = 244 -- so 238 is the last opaque frame,
|
|
## not the arrival. `236 - 118 = 118`. The port has been printing the
|
|
## contradiction in one sentence on every boot: *"plate reaches full alpha at
|
|
## t=236 ... 120 units after build 4's last build-in ramp at t=118"*. The
|
|
## correction moves the reconciliation by 0.033 s and changes no conclusion,
|
|
## which is why it survived; a cited number that is wrong is still wrong.
|
|
## See `docs/port/plate-arrival-halves.md`.
|
|
##
|
|
## A second ScreenView rather than a second screen inside one, because that is
|
|
## what "two builds at once" actually is: each has its own timeline, its own
|
|
## textures and its own hold, and Node2D siblings already draw in tree order.
|
|
## Teaching ScreenView about a subordinate screen would have been the same
|
|
## information expressed less directly, and would have put an `if overlay` in
|
|
## every method that walks elements.
|
|
var overlay: ScreenView = 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
|
|
|
|
# 🔴 BEFORE ANYTHING ELSE, and announced. Godot 4.7.2 binds no joypad button
|
|
# to `ui_accept` or `ui_cancel`, so on a real pad Ⓐ and Ⓑ did nothing while
|
|
# navigation worked — which reads as a broken controller. `Gamepad` explains
|
|
# it. Both lines are printed because "the pad is seen" and "Ⓐ is bound" are
|
|
# different facts and the human debugging this needs to separate them.
|
|
var _pad_bound := Gamepad.bind_missing()
|
|
if _pad_bound != "":
|
|
print(_pad_bound)
|
|
print(Gamepad.report_devices())
|
|
var export_tree := ExportTree.locate()
|
|
_tree = export_tree
|
|
if export_tree.root == "":
|
|
push_error(export_tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
|
|
# Say it before anything is drawn. A modded run that looked identical to an
|
|
# unmodded one in the log would leave a modder with exactly one debugging
|
|
# tool -- delete the mod and try again.
|
|
var mods := export_tree.mod_report()
|
|
if mods != "":
|
|
print(mods)
|
|
|
|
# Parsed HERE, before the first `ScreenView` is configured -- it was briefly
|
|
# read further down and every `--screen` run silently ignored it.
|
|
if args.has("loop-phase"):
|
|
_loop_phase = float(args["loop-phase"])
|
|
_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)
|
|
# P6. Audio is loaded even for a static `--screen` run: it costs nothing when
|
|
# the export has none, and a mode that silently cannot play sound is a mode
|
|
# that hides the failure this milestone is about.
|
|
audio = MenuAudio.new()
|
|
add_child(audio)
|
|
if not audio.configure(export_tree):
|
|
push_error(audio.error)
|
|
get_tree().quit(2)
|
|
return
|
|
if audio.silent():
|
|
print("this export carries no audio -- run the exporter against a disc for P6")
|
|
_record_to = args.get("audio", "")
|
|
if _record_to != "":
|
|
_start_recording()
|
|
|
|
# In `--boot` the capture is taken at the END, not in `_ready`: the frame
|
|
# worth having is the composited title, and `_ready` runs 150 s before it.
|
|
if args.has("boot"):
|
|
_capture_to = args.get("capture", "")
|
|
_film = args.get("film", "")
|
|
# `--film-interval=` in seconds. Configurable because the fixed 0.25 s could
|
|
# not resolve the boot's own black hold: the transition's pure-black plateau
|
|
# is MEASURED at 0.17-0.23 s (authored/timing.json, HANDOFF Q7), which is
|
|
# shorter than the cadence that was meant to observe it. `verify-dwell` duly
|
|
# reported two screens as one 93 s span and called it a regression, when the
|
|
# black frame had simply fallen between samples.
|
|
_film_interval = maxf(0.01, float(args.get("film-interval", "0.25")))
|
|
_shots = args.get("shots", "")
|
|
if args.has("script"):
|
|
_script = args["script"].split(",", false)
|
|
_skip_at = float(args.get("skip-at", "0"))
|
|
# `--focus=<element id>` draws a button's focus record in a `--screen` run,
|
|
# which otherwise focuses nothing. A diagnostic: the spinning ring is only
|
|
# drawn on a FOCUSED button, so without this the ring can only be observed in
|
|
# a live `--menu` run, where the film cadence jitters by up to a frame and a
|
|
# 3-degree angular wobble swamps the "identical one period apart" check that
|
|
# verified it at P5.
|
|
_force_focus = args.get("focus", "")
|
|
# 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")
|
|
# 🔴 `--boot --script=…` PARSED, WAS STORED, AND DID NOTHING. The script only
|
|
# ever starts at `_menu_enter`, and a `--boot` run without `--play` never
|
|
# enters a menu: it holds on the title and quits. So the run completed, exit
|
|
# 0, no menu line, no press -- a clean-looking result to a question that was
|
|
# never asked.
|
|
#
|
|
# This file already warns about exactly this shape 600 lines up, where
|
|
# `--capture` used to photograph the first frame of a scripted run: "a flag
|
|
# combination that silently photographs the wrong instant is worse than one
|
|
# that errors". Same class, found again by running the port as a player would
|
|
# rather than by reading it.
|
|
#
|
|
# Refusing rather than implying `--play`: the two runs differ by 157 seconds
|
|
# of intro, and quietly choosing that for someone is its own surprise.
|
|
if not _script.is_empty() and not _play:
|
|
push_error("--script needs a live menu. `--boot` alone ends on the title "
|
|
+ "and quits, so the script would never run. Use `--boot --play "
|
|
+ "--script=…` to walk from power-on (157 s of intro), or `--menu=<screen> "
|
|
+ "--script=…` to start on a screen.")
|
|
get_tree().quit(2)
|
|
return
|
|
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"])
|
|
# WHAT THIS USED TO BE, stated first because the sentence below is what a
|
|
# reader would otherwise carry away: "the one unknown duration per screen --
|
|
# the ramp into the final untimed keyframe, authored because the disc has no
|
|
# time slot there." That is pre-fix and false in both halves.
|
|
# `exit_ramp_units` is DELETED from authored/timing.json -- under the corrected
|
|
# record layout every pose is timed, so there is no untimed final keyframe to
|
|
# give a synthetic time to. The default below is now unreachable rather than
|
|
# authored, and both of ScreenView's uses are dead branches kept only so an
|
|
# older export still loads.
|
|
# ⚠️ The fallback is -1.0, NOT 24.0. It was 24.0 -- the constant HANDOFF ask 2
|
|
# told this port to author and that it refused -- so deleting the authored
|
|
# entry as progress silently reinstated the refuted number as a default.
|
|
# Negative means "not supplied": ScreenView then declines to invent a duration
|
|
# and says so, rather than making one up. See ScreenView.exit_ramp_units.
|
|
view.exit_ramp_units = float(timing.get("exit_ramp_units", -1.0))
|
|
# ⚠️ THE FALLBACK IS -1.0, NOT 0.0, EVEN THOUGH THE AUTHORED VALUE IS 0.
|
|
#
|
|
# This is the `exit_ramp_units` shape in waiting: a default that EQUALS the
|
|
# authored value makes deleting the authored entry invisible -- same
|
|
# behaviour, no error, and the reasoning in `black_hold_why` (four measured
|
|
# gaps, why 0 rather than the best-fitting 4 or 6, and the tripwire for
|
|
# revisiting it) silently stops applying to anything.
|
|
#
|
|
# The Decoder's sharpening is what surfaced it: an IN-RANGE fallback cannot
|
|
# be caught by inspecting output, because the output looks exactly like the
|
|
# true case. 0 is a legitimate hold. So the absence is made loud instead.
|
|
_black_hold = float(timing.get("black_hold_units", -1.0))
|
|
if _black_hold < 0.0:
|
|
push_error("authored/timing.json has no black_hold_units. Using 0, which is "
|
|
+ "what it said -- but the REASONING for 0 lived there and is now gone. "
|
|
+ "See black_hold_why in git history before trusting this transition.")
|
|
_black_hold = 0.0
|
|
# 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", {})
|
|
# Called, not merely defined. A validator nobody invokes is the same defect
|
|
# it exists to catch.
|
|
_check_authored_invariants(timing)
|
|
# 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", [])
|
|
_loop_leaf_screens = [] if rendering == null else rendering.get("loop_leaf_on_screens", [])
|
|
# F6: the leaf's clock relative to its screen's. Absent or null -> the leaf
|
|
# runs on the screen clock, which is what this port has always done and is
|
|
# itself an unmeasured assumption (offset 0, rate 1). See ScreenView.leaf_clock.
|
|
_leaf_clock = {} if rendering == null else rendering.get("leaf_clock", {})
|
|
# `additive_elements` is DELETED from authored/rendering.json -- the blend is
|
|
# decoded now (`T8aD +0x04` bit 0x02) and the exporter emits `blend_additive`
|
|
# per element, which `ScreenView._draw` reads directly. Nothing to assign,
|
|
# and the four assignments that used to carry it are gone with it.
|
|
# `--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
|
|
# ask "is the port's held pose what the idle game shows" is to be able to
|
|
# render the other answer. Added when the title's 1.8 % disagreement with the
|
|
# oracle could not be attributed without it.
|
|
#
|
|
# It goes HERE and not with the other flags: `view` does not exist until this
|
|
# point, and the first version set it thirty lines too early and silently
|
|
# rendered nothing.
|
|
if args.has("no-hold"):
|
|
view.holding = false
|
|
# 🔴 THE BRANCH ANNOUNCES ITSELF, because a request that is silently
|
|
# overridden reads exactly like one that worked. `--time` sets `frozen`,
|
|
# and `pose_at` tests `holding and not frozen`, so an explicit instant
|
|
# makes this inert -- measured: identical renders with `--time`, max 253
|
|
# different without it. I documented that combination as an EXAMPLE
|
|
# before testing it, and only running it caught the no-op.
|
|
if args.has("time"):
|
|
print(" --no-hold: INERT -- --time sets `frozen`, which overrides holding")
|
|
else:
|
|
print(" --no-hold: playing past the rest, not clamping at each hold")
|
|
viewport.add_child(view)
|
|
|
|
view.looping_focus = _looping_for(name)
|
|
view.loop_phase_units = _loop_phase
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
var lc: Dictionary = _leaf_clock.get(name, {})
|
|
view.leaf_start_units = float(lc.get("start_units", -1.0)) if lc.get("start_units") != null else -1.0
|
|
view.leaf_rate = float(lc.get("rate", -1.0)) if lc.get("rate") != null else -1.0
|
|
view.focused_id = _force_focus
|
|
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)
|
|
|
|
view.structural_skips.clear()
|
|
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])
|
|
|
|
# `--leaf-time=<seconds>` places the LEAF alone, leaving the screen settled.
|
|
# See `ScreenView.leaf_time_units`.
|
|
if args.has("leaf-time"):
|
|
view.leaf_time_units = float(args["leaf-time"]) * view.units_per_second
|
|
view.queue_redraw()
|
|
|
|
if args.has("time"):
|
|
_frozen = true
|
|
# An explicit instant beats the settle instant -- see `ScreenView.frozen`.
|
|
view.frozen = true
|
|
view.time_units = float(args["time"]) * view.units_per_second
|
|
view.queue_redraw()
|
|
|
|
if _film != "":
|
|
set_process(true)
|
|
_film_capture()
|
|
|
|
# `--overlay=<screen>` composites a second build immediately, without waiting
|
|
# for a boot. It exists because the only other way to see two builds at once
|
|
# is a 156 s `--boot`, of which 137 s is the intro movie -- which under Xvfb's
|
|
# software Theora decode is several minutes to answer "is the plate on top of
|
|
# the title". This raises the same second `ScreenView` by the same code path,
|
|
# so what it photographs is the real composite and not a mock-up. It applies
|
|
# NO delay: the delay is a measurement and lives in `authored/flow.json`,
|
|
# where the boot reads it.
|
|
# A boot whose FIRST step declares an overlay: `_advance` raises it for every
|
|
# later step, and `_ready` is the one with no `_advance` in front of it.
|
|
# Today only the last step has one, so this is a guard rather than a fix --
|
|
# but a silently missing second build is exactly the failure P3 just spent an
|
|
# iteration on.
|
|
if not _sequence.is_empty() and typeof(_sequence[0].get("overlay", null)) == TYPE_DICTIONARY:
|
|
_overlay_spec = _sequence[0]["overlay"]
|
|
_overlay_due = 0.0
|
|
_overlay_process(0.0)
|
|
|
|
if args.has("overlay") and not args.has("boot"):
|
|
# 🔴 THIS IS THE ONLY STATIC OVERLAY, AND IT NOW SAYS SO ITSELF.
|
|
# `_overlay_process` used to detect it as `_sequence.is_empty()`, which is
|
|
# true for `--menu` as well -- `_sequence` is populated only by `--boot`.
|
|
# So the MENU's return-to-title took the diagnostic branch and posed the
|
|
# plate at its settle instantly. Measured: the overlay clock jumped 0 ->
|
|
# 244.67 units in one frame, and the plate POPPED where the boot fades it
|
|
# across its declared 214->236.
|
|
_static_overlay = true
|
|
_overlay_spec = {"screen": args["overlay"]}
|
|
_overlay_due = 0.0
|
|
_overlay_process(0.0)
|
|
if overlay != null and args.has("time"):
|
|
overlay.frozen = true
|
|
overlay.time_units = float(args["time"]) * overlay.units_per_second
|
|
overlay.queue_redraw()
|
|
|
|
# `--boot --capture=` is deferred to the end of the sequence (`_finish_boot`),
|
|
# and so is `--script --capture=`.
|
|
#
|
|
# 🔴 The second half of that was missing, and the comment here used to assert
|
|
# the opposite -- "in every other mode the frame worth having is this one".
|
|
# With `--script` it is emphatically not: the capture fired **before the
|
|
# first press**, at t=0.133 s, with 10 of 16 elements still transparent, and
|
|
# then quit. Two runs differing by two `down` presses came out BIT-IDENTICAL,
|
|
# because neither had run its script when it was photographed.
|
|
#
|
|
# That is not a harmless default. It is a well-formed answer to a different
|
|
# question, and it produced a confident wrong finding -- "runtime focus never
|
|
# changes" -- that `--shots` immediately contradicted. A flag combination
|
|
# that silently photographs the wrong instant is worse than one that errors.
|
|
if args.has("capture") and _capture_to == "":
|
|
if not _script.is_empty():
|
|
# Defer to the end of the script, through the SAME member the boot
|
|
# path already uses, rather than adding a second mechanism.
|
|
_capture_to = String(args["capture"])
|
|
else:
|
|
# 🔴 "THE SETTLED POSE BY OMISSION" WAS AN ACCIDENT AND IS NOW REAL.
|
|
#
|
|
# `_capture` shoots after two frames, so a `--screen=X --capture=`
|
|
# run photographed t ~= 2 units -- the very start of the build-in.
|
|
# It looked settled only because `pose_at` used to ASSIGN
|
|
# `settle_instant` rather than clamp to it, handing back the settled
|
|
# pose whatever the clock said. `tools/port/verify-capture` says so
|
|
# in its own words: "the 0.01 % agreements on both splashes were
|
|
# measured through that accident."
|
|
#
|
|
# Fixing the assignment (see `ScreenView.pose_at`) removed the
|
|
# accident and the tool started photographing a mid-ramp frame, which
|
|
# is a change in the HARNESS's shot, not in what the port ships. So
|
|
# the clock is advanced to the settle instant explicitly, which is
|
|
# what the tool was always asking for.
|
|
#
|
|
# ⚠️ Only when nothing pinned an instant. `--time` means the caller
|
|
# wants THAT instant and `frozen` is already set; overriding it here
|
|
# would reintroduce exactly the silent-ignore this replaces.
|
|
if not _frozen and view != null and view.settle_instant >= 0.0:
|
|
view.time_units = maxf(view.time_units, view.settle_instant)
|
|
view.queue_redraw()
|
|
await _capture(args["capture"])
|
|
get_tree().quit(0)
|
|
|
|
|
|
var _frozen := false
|
|
## Holds the left stick's latch state. See `gamepad.gd`.
|
|
var _pad := Gamepad.new()
|
|
var _flow: Variant = null
|
|
var _menu: MenuFlow = null
|
|
var _play := false
|
|
var _pending: Variant = null
|
|
## `authored/timing.json` `looping_focus_records`, keyed `<screen>/<element>`.
|
|
var _looping: Dictionary = {}
|
|
## `authored/rendering.json` `draw_leaf_for`.
|
|
var _draw_leaf_for: Array = []
|
|
## `authored/rendering.json` `additive_elements` -- measured off the running game.
|
|
## `authored/rendering.json` `leaf_clock`: screen -> {start_units, rate}.
|
|
var _leaf_clock: Dictionary = {}
|
|
|
|
## `authored/rendering.json` `loop_leaf_on_screens`.
|
|
var _loop_leaf_screens: Array = []
|
|
var _script: PackedStringArray = PackedStringArray()
|
|
var _shots := ""
|
|
## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0.
|
|
var _skip_at := 0.0
|
|
## Units of pure black between one screen leaving and the next arriving.
|
|
var _black_hold := 0.0
|
|
## `--focus=<id>`: draw this element's focus record in a `--screen` run.
|
|
var _force_focus := ""
|
|
var _skip_sent := false
|
|
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
|
|
## Seconds between `--film` frames. See `--film-interval`.
|
|
var _film_interval := 0.25
|
|
var _elapsed := 0.0
|
|
var _boot_done := false
|
|
|
|
|
|
## Frames rendered on the current screen, and the worst gap between two of them.
|
|
##
|
|
## 🔴 THIS PORT REQUIRED EVERY MEASUREMENT TO REPORT ITS ACHIEVED RATE AND NEVER
|
|
## REPORTED ITS OWN. `TEMPORAL-VERIFICATION.md` §1 -- a capture that asked for one
|
|
## rate and delivered another "is not a slow capture, it is a different capture"
|
|
## -- was applied to `--film`, to the Decoder's harnesses and to the oracle, and
|
|
## not once to the thing actually being shipped.
|
|
##
|
|
## It matters here specifically. The splashes are the current focus, the
|
|
## developer splash's whole build-in is 45 units = 0.75 s, and the human's
|
|
## complaint about it is that ours is *less pronounced* than the game's. A fade
|
|
## drawn in 45 frames and the same fade drawn in 11 are different animations, and
|
|
## nothing in this port could have told the difference. Every timing check so far
|
|
## has measured `_elapsed`, which is the sum of the deltas -- it is exactly as
|
|
## correct at 8 fps as at 60 and says nothing about what a person sees.
|
|
##
|
|
## `worst` is kept beside the mean because a hitch is what reads as wrong: a
|
|
## screen averaging 55 fps with one 400 ms stall looks broken, and a mean hides
|
|
## that by construction.
|
|
var _screen_frames := 0
|
|
var _screen_t0 := 0.0
|
|
var _worst_gap := 0.0
|
|
|
|
|
|
## What the port managed on the screen just finishing, in the form this project
|
|
## demands of everyone else: achieved against requested, plus the worst hitch.
|
|
func _rate_report(name: String) -> String:
|
|
var span := _elapsed - _screen_t0
|
|
if span <= 0.0 or _screen_frames == 0:
|
|
return ""
|
|
# 🔴 THE "REQUESTED" HALF PRINTED -9223372036854775808 ON ITS FIRST RUN.
|
|
# `DisplayServer.screen_get_refresh_rate()` returns a float and is -1.0 when
|
|
# the display cannot say -- which Xvfb cannot -- and `%d` on that underflows
|
|
# to INT64_MIN. A rate line whose own denominator is nonsense is worse than
|
|
# no rate line, so it now names the cap or says plainly that there is none.
|
|
var cap := "uncapped"
|
|
if Engine.max_fps > 0:
|
|
cap = "%d requested" % Engine.max_fps
|
|
else:
|
|
var hz := DisplayServer.screen_get_refresh_rate()
|
|
if hz > 0.0:
|
|
cap = "%.0f Hz display" % hz
|
|
return " %s: %d frames in %.2f s -- %.1f fps achieved, %s, worst gap %.0f ms" % [
|
|
name, _screen_frames, span, _screen_frames / span, cap, _worst_gap * 1000.0]
|
|
|
|
|
|
func _rate_reset() -> void:
|
|
_screen_frames = 0
|
|
_screen_t0 = _elapsed
|
|
_worst_gap = 0.0
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if _frozen or view == null:
|
|
return
|
|
view.time_units += delta * view.units_per_second
|
|
_elapsed += delta
|
|
_screen_frames += 1
|
|
_worst_gap = maxf(_worst_gap, delta)
|
|
view.queue_redraw()
|
|
_overlay_process(delta)
|
|
_menu_repeat(delta)
|
|
|
|
if _player != null:
|
|
# `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie,
|
|
# which `--script` structurally cannot do: `_script_settled` waits while
|
|
# `_player != null`, so a scripted walk only ever starts after the movie
|
|
# has ended. That gap is why "does (A) skip the intro" had been read out
|
|
# of the source rather than measured, and a human play-test then found
|
|
# it not working.
|
|
#
|
|
# It goes through `Input.parse_input_event`, like `_press` -- the wiring
|
|
# between a press and `_unhandled_input` is the thing under test, so a
|
|
# direct call to `_video_finished` would prove nothing.
|
|
if _skip_at > 0.0 and _elapsed >= _skip_at and not _skip_sent:
|
|
_skip_sent = true
|
|
print(" --skip-at: pressing (A) at %.2f s" % _elapsed)
|
|
_press("ui_accept")
|
|
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 --
|
|
# and for the two splashes that is now MEASURED to be right, not merely
|
|
# cautious. Their dwells are declared: publisher t=0..255, developer
|
|
# t=0..210, corroborated over 3 cold boots to 1.1 % on the developer. The
|
|
# port emits each declared value plus ~1.4 units of frame granularity.
|
|
#
|
|
# 🔴 THIS SAID "plus the 9-unit black hold, exactly" AND THE PORT DOES NOT DO
|
|
# THAT. `authored/timing.json` sets `black_hold_units = 0` -- deliberately,
|
|
# with its own argument that a uniform value is positively EXCLUDED and only
|
|
# an ordered-pair key survives -- so there is no 9-unit hold to add. The
|
|
# sentence described a behaviour the file two lines up refuses to have.
|
|
#
|
|
# Measured over three boots (2026-09-01), and the residual is frame
|
|
# granularity, not a hold:
|
|
#
|
|
# publisher declared 255 units = 4.250 s measured 4.28 / 4.26 / 4.27
|
|
# mean 4.270 s, residual +1.2 units
|
|
# developer declared 210 units = 3.500 s measured 3.50 / 3.57 / 3.51
|
|
# mean 3.527 s, residual +1.6 units
|
|
#
|
|
# The claimed 4.400 / 3.650 are each ~0.13 s longer than what the port has
|
|
# been shipping. Nothing changed here: the CLAIM was wrong, not the code.
|
|
#
|
|
# ⚠️ The title is the exception and it is why this loop leaves the LAST screen
|
|
# alone: build 4 declares ~120 presented frames and dwells ~1100, because its
|
|
# exit is caused by something outside its timeline. A splash's exit is caused
|
|
# by nothing, so it plays out. Do not generalise either one to the other --
|
|
# a previous revision of this comment did, in both directions.
|
|
#
|
|
# `_advance` is CAUSED by the next screen arriving, never scheduled off a
|
|
# timer, which is what the draw stream says the game does.
|
|
# `authored/flow.json` `dwell` is applied at the EXIT below, not here.
|
|
#
|
|
# 🔴 I wired it here first and it did nothing, silently -- which is the
|
|
# defect it exists to remove, reproduced while removing it. Holding longer
|
|
# after settle changes nothing, because the screen still leaves when
|
|
# `exit_time() + black_hold` arrives and the extra hold is absorbed. A dwell
|
|
# has to delay the DEPARTURE.
|
|
#
|
|
# It was read NOWHERE for eight milestones. The
|
|
# block's own text says "when a capture times the real boot, the extra hold
|
|
# per screen goes here" -- and a number placed there did nothing at all. Two
|
|
# iterations ago I asked the Decoder for measurements destined for that slot;
|
|
# had they arrived, they would have been filed into a value with no reader
|
|
# and the boot would have been unchanged, silently.
|
|
#
|
|
# It stays EMPTY. Nothing is authored into it, because the splash dwells are
|
|
# declared on the disc and measured to agree. This wires the slot so the day
|
|
# a number belongs there it has an effect, which is the opposite of adopting
|
|
# one now.
|
|
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].get("screen", _sequence[_step])])
|
|
# The LAST screen never reaches `_advance`, so without this the one
|
|
# screen the boot ends on -- the title, where the plate lands -- was
|
|
# the only one with no rate reported.
|
|
var last := _rate_report(String(view.screen.get("name", "?")))
|
|
if last != "":
|
|
print(last)
|
|
# The plate is timed from HERE -- the moment the screen reaches its
|
|
# own hold -- and not from the frame it first appeared. That is the
|
|
# finding, not a detail: measured from first-draw the two oracle
|
|
# runs disagree by 0.48 s, because the build-in's own duration is
|
|
# the emulator's frame pacing rather than the game's clock.
|
|
if overlay != null:
|
|
# It was raised with the screen, 120 units ago. Nothing to do
|
|
# here any more -- this hook used to start an authored 2.13 s
|
|
# timer, and the timer was the bug.
|
|
pass
|
|
# 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 == "" and _overlay_spec.is_empty() and _overlay_quit_at < 0.0:
|
|
get_tree().quit(0)
|
|
elif not view.holding and view.time_units >= view.exit_time() + _black_hold \
|
|
+ _dwell_for(String(_sequence[_step].get("screen", ""))):
|
|
# 🔴 THE BLACK HOLD, which this port had never implemented. A transition
|
|
# is a fade THROUGH black (HANDOFF Q7), and the pure-black plateau
|
|
# between one screen leaving and the next arriving was measured at
|
|
# 0.17-0.23 s. Filmed at 0.05 s the port fell straight from the
|
|
# publisher's fade-out into the developer logos with NO BLACK FRAME.
|
|
#
|
|
# The menus' transition quad declares black for 12 units and 12/60 =
|
|
# 0.200 s sits in the middle of the measured range -- but the boot
|
|
# splashes carry no such quad (`palogo_eff0` is one static keyframe), so
|
|
# on this path it is authored. See `authored/timing.json`.
|
|
_advance()
|
|
|
|
|
|
func _advance() -> void:
|
|
# Before the step changes, say what the screen that is leaving managed.
|
|
if view != null:
|
|
var leaving := String(view.screen.get("name", "?"))
|
|
var line := _rate_report(leaving)
|
|
if line != "":
|
|
print(line)
|
|
_rate_reset()
|
|
_drop_overlay()
|
|
_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
|
|
view.looping_focus = _looping_for(name)
|
|
view.loop_phase_units = _loop_phase
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
var lc: Dictionary = _leaf_clock.get(name, {})
|
|
view.leaf_start_units = float(lc.get("start_units", -1.0)) if lc.get("start_units") != null else -1.0
|
|
view.leaf_rate = float(lc.get("rate", -1.0)) if lc.get("rate") != null else -1.0
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
# The second build starts WITH the first, not after it. Raised here rather
|
|
# than at the screen's settle, which is what the authored-delay version did.
|
|
var spec: Variant = next.get("overlay", null)
|
|
if typeof(spec) == TYPE_DICTIONARY:
|
|
_overlay_spec = spec
|
|
_overlay_due = _elapsed
|
|
_overlay_process(0.0)
|
|
|
|
|
|
## 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"]])
|
|
# 🔴 THE MENU BED KEEPS PLAYING UNDER THE MOVIE, AND NOBODY DECIDED THAT.
|
|
#
|
|
# `MenuAudio.stop_bed()` exists and is called from nowhere, so the music
|
|
# started on the main menu runs through the cutscene and on past it. That is
|
|
# an UNMADE DECISION, not a choice: the movie carries its own music and
|
|
# effects, so the port emits two unrelated music tracks at once, measured at
|
|
# r=0.42 for the bed inside the movie's own window (docs/port/DECISIONS.md).
|
|
#
|
|
# It is NOT silenced here, deliberately. PORT-MISSION's rule is to leave an
|
|
# unmeasured detail PLAINLY WRONG rather than plausibly invented, and this is
|
|
# the textbook case: music over a cutscene is wrong in a way any listener
|
|
# catches in one second, whereas stopping it would sound perfectly right and
|
|
# be a guess about the game nobody has watched. The audible version gets
|
|
# fixed; the plausible version ships forever.
|
|
#
|
|
# So it says so instead. Announcing the gap before opening it is what
|
|
# `skipped_chain` already does for NEW GAME.
|
|
if audio.bed_playing():
|
|
print(" 🔴 the menu bed is STILL PLAYING under this movie -- unmeasured,")
|
|
print(" left audible on purpose (BLOCKED.md: does menu music duck?)")
|
|
|
|
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()
|
|
_video_meta = v
|
|
_video_started_at = _elapsed
|
|
_frames_at_video_start = Engine.get_frames_drawn()
|
|
# The dialogue is a SECOND stream, started with the picture. `ADV.wmv` and
|
|
# `S00A.wmv` carry music and effects only; the voice is a separate asset the
|
|
# exporter resolves off the movie manifest. Started after `play()` and in the
|
|
# same frame, because the offset between them is zero and adding a wait here
|
|
# would be authoring a sync constant nobody measured.
|
|
if audio.play_voice(name):
|
|
print(" + voice %s" % name)
|
|
# 🔴 SAY WHAT IS MISSING, at the moment it is played.
|
|
#
|
|
# The manifest has known the voice export is incomplete for weeks and the
|
|
# runtime did not repeat it. That asymmetry is the dangerous one for
|
|
# audio: a reader of `manifest.json` gets a paragraph, and a person
|
|
# LISTENING gets clean dialogue with no way to learn a stream is absent.
|
|
# The same principle already governs NEW GAME, which announces the two
|
|
# measured screens it jumps over rather than skipping them silently.
|
|
var gap := audio.incomplete_for(name)
|
|
if gap != "":
|
|
print(" 🔴 KNOWN INCOMPLETE: %s" % gap)
|
|
else:
|
|
# Said out loud: silence is the audio failure that looks like success,
|
|
# and "this cutscene is unvoiced" is a real answer for most of the disc.
|
|
print(" no voice track for %s in this export" % name)
|
|
|
|
|
|
var _skippable := false
|
|
|
|
|
|
## What the last movie actually presented, printed on every run.
|
|
##
|
|
## 🔴 PERMANENT ON PURPOSE. This port asserted that a player running longer than
|
|
## its media must have presented every frame -- argued from the absence of a
|
|
## visible drop rather than measured.
|
|
##
|
|
## 🔴 CORRECTED 2026-09-01, and the correction is of a correction. This read that
|
|
## the measurement "refuted the claim outright: 28 % of `S00A`'s frames [refuted] and 47 %
|
|
## of `ADV`'s reached the screen". **Retracted.** Those runs were contended, and
|
|
## this counts ENGINE frames -- an upper bound that constrains nothing once the
|
|
## engine outruns the stream, which it does: quiet, `ADV` draws 6 480 across a
|
|
## 4 123-frame video. The original claim is still unsupported; the numbers that
|
|
## were said to refute it do not.
|
|
##
|
|
## So the count is not a diagnostic to reach for, it is printed by default. An
|
|
## instrument that has to be added before the question can be asked is one that
|
|
## will not be there the next time somebody reasons instead.
|
|
##
|
|
## 🔴 AND IT IS AN UPPER BOUND, NOT A COUNT -- corrected the same day it was
|
|
## added, because the first reading of it was wrong. It counts ENGINE frames. A
|
|
## player cannot show more frames than the engine draws, so this bounds shown
|
|
## frames from above -- but the engine renders the UI at its OWN rate, and on a
|
|
## quiet box it drew **6 480 frames across a 4 123-frame `ADV`**, 44 fps against
|
|
## the media's 30. Above that crossover the bound constrains nothing, and the
|
|
## report says so rather than printing "157 % presented".
|
|
##
|
|
## ⚠️ It says nothing about how many frames were DECODED either; Theora is
|
|
## inter-frame predicted, so a decoder may decode frames it never displays.
|
|
var _video_meta: Dictionary = {}
|
|
var _video_started_at := 0.0
|
|
var _frames_at_video_start := 0
|
|
|
|
|
|
func _video_report() -> void:
|
|
var dur := float(_video_meta.get("duration_s", 0.0))
|
|
var fps := float(_video_meta.get("fps", 0.0))
|
|
var span := _elapsed - _video_started_at
|
|
var drawn := Engine.get_frames_drawn() - _frames_at_video_start
|
|
if dur <= 0.0 or fps <= 0.0:
|
|
print(" presented %d engine frame(s) in %.2f s -- the manifest carries no"
|
|
% [drawn, span] + " duration, so nothing to compare against")
|
|
return
|
|
var expected := dur * fps
|
|
var timing := "%.2f s for %.2f s of media (%+.1f%%)" % [span, dur, 100.0 * (span - dur) / dur]
|
|
if drawn >= expected:
|
|
# 🔴 THE BOUND IS VACUOUS HERE AND MUST SAY SO. The engine renders the UI
|
|
# at its own rate, not the movie's: a quiet box drew 6 480 frames across
|
|
# a 4 123-frame `ADV`, 44 fps against the media's 30. "157 % presented"
|
|
# is not a measurement, it is the counter being used outside the range
|
|
# where it constrains anything.
|
|
print(" %d engine frame(s) across %d in the media -- engine faster than"
|
|
% [drawn, int(round(expected))]
|
|
+ " the stream, so this bounds NOTHING about frames shown; %s" % timing)
|
|
return
|
|
print(" at most %d of %d frame(s) shown (%.0f%% upper bound) in %s"
|
|
% [drawn, int(round(expected)), 100.0 * drawn / expected, timing])
|
|
|
|
|
|
func _video_finished() -> void:
|
|
_video_report()
|
|
print(" video ended at %.2f s" % _elapsed)
|
|
# Before anything else: a voice that outlived a skipped intro would play on
|
|
# over the title screen, which is the sort of bug that sounds like a feature.
|
|
audio.stop_voice()
|
|
_player.queue_free()
|
|
_player = null
|
|
# A movie the MENU started (P7) returns to an authored screen; a movie the
|
|
# BOOT started advances the sequence. Two different owners, and conflating
|
|
# them walked the boot sequencer off the end of its own array.
|
|
if not _video_then.is_empty():
|
|
var after := _video_then
|
|
_video_then = {}
|
|
var goto := String(after.get("goto", ""))
|
|
print(" -> %s (authored: %s)" % [goto, String(after.get("kind", "authored"))])
|
|
_menu_activate({"kind": "enter", "goto": goto, "label": "after the movie"})
|
|
# `_menu_activate` only arms the transition; the screen it is leaving has
|
|
# already gone, so arrive immediately rather than fading out a movie.
|
|
if _pending != null:
|
|
_menu_arrive()
|
|
return
|
|
_advance()
|
|
|
|
|
|
## Where a menu-started movie goes when it ends. Empty for a boot-started one.
|
|
var _video_then: Dictionary = {}
|
|
|
|
|
|
## Say what the mods directory contributed, and what it did not.
|
|
##
|
|
## The shadow lines are printed as they happen; this is the other half -- files
|
|
## that matched nothing. Without it a mistyped override is indistinguishable
|
|
## from a working one to the person who wrote it, which makes the whole
|
|
## base-and-overrides arrangement (MODDING rule 4) unusable by its own audience.
|
|
## The export tree, kept so `_exit_tree` can report what the mods directory did
|
|
## NOT contribute. It was a local in `_ready`, which is why the unused-override
|
|
## report could not exist until now.
|
|
var _tree: ExportTree = null
|
|
|
|
|
|
func _report_unused_mods() -> void:
|
|
if _tree == null:
|
|
return
|
|
var unused := _tree.unused_mods()
|
|
if unused.is_empty():
|
|
return
|
|
print("mods: %d file(s) in data/mods can shadow NOTHING -- no such path in the"
|
|
% unused.size() + " export:")
|
|
for rel in unused:
|
|
print(" inert: %s" % rel)
|
|
print(" ⚠️ These are not \"not reached yet\": a file whose path exists in the")
|
|
print(" export but was not read this run is NOT listed. Every line above is")
|
|
print(" an override that can never apply, whatever the run does.")
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
# The left stick is bound to ui_up/ui_down by Godot's own defaults, and an
|
|
# analog axis is not an edge: held at deflection it emits an event per
|
|
# jitter, each reporting the action as pressed. `accepts()` latches it to one
|
|
# step per deflection. Everything already edge-shaped passes through
|
|
# untouched. See `gamepad.gd` -- including what is authored about it.
|
|
if not _pad.accepts(event):
|
|
return
|
|
# 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), "confirm")
|
|
elif event.is_action_pressed("ui_cancel"):
|
|
_menu_activate(_menu.cancel(), "back")
|
|
|
|
|
|
## A held direction repeats. F1 of the 2026-09-02 menu play-test: the real game
|
|
## continues to move while up or down is held, on the stick AND on the d-pad.
|
|
##
|
|
## 🔴 **This currently does nothing, and that is the intended state.** The
|
|
## mechanism is here; the RATE is not, because it has not been measured and an
|
|
## invented one would be indistinguishable from a measurement later. `Gamepad`
|
|
## returns 0 from `repeat_due()` until `REPEAT_DELAY`/`REPEAT_INTERVAL` are set.
|
|
##
|
|
## The guards are deliberately the SAME conditions `_unhandled_input` applies to
|
|
## a real press -- a repeat that could fire during a movie, mid-transition or on
|
|
## a screen with no menu would be a second, subtly different input path, and the
|
|
## first thing this port learned about input is that a second path is where the
|
|
## defect hides.
|
|
func _menu_repeat(delta: float) -> void:
|
|
var step := _pad.repeat_due(delta)
|
|
if step == 0:
|
|
return
|
|
if _player != null or _menu == null or _menu.stack.is_empty() or _pending != null:
|
|
return
|
|
_menu_move(step, view.screen.get("buttons", []))
|
|
|
|
|
|
func _menu_move(step: int, buttons: Array) -> void:
|
|
# MEASURED, HANDOFF Q8 + Q5: the cue fires on a press that MOVES the cursor.
|
|
# `move()` returns whether it did, so a press that changes nothing cannot
|
|
# click -- which also means left/right stay silent by construction rather
|
|
# than by a rule written twice.
|
|
if _menu.move(step, buttons):
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
audio.play("move")
|
|
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, cue: String = "") -> void:
|
|
# AUTHORED, NOT MEASURED: the cue fires when the press does something, and
|
|
# not when nothing is bound to it. Nobody has watched the game take a dead
|
|
# press. Silence invents the less of the two -- a sound the game does not
|
|
# make is a wrong fact you can hear. `blocked` counts as doing something:
|
|
# that destination WAS measured off the running game and is missing from
|
|
# this export, not from the game. See port/scripts/menu_audio.gd.
|
|
if cue != "" and String(action.get("kind", "none")) != "none":
|
|
audio.play(cue)
|
|
match String(action.get("kind", "none")):
|
|
"enter":
|
|
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
|
|
_pending = action
|
|
view.holding = false
|
|
"video":
|
|
# P7. Announce the gap before opening it. `skipped` names the
|
|
# MEASURED screens this export does not carry, and printing them is
|
|
# not a nicety: the port is about to show a sequence the game does
|
|
# not have, and the only thing that keeps that honest is saying so.
|
|
var skipped: Array = action.get("skipped", [])
|
|
if not skipped.is_empty():
|
|
print(" (%s) -> the real chain is %s, then the movie. Neither screen is in this export."
|
|
% [action.get("label", ""), " -> ".join(PackedStringArray(skipped))])
|
|
_video_then = action.get("after", {})
|
|
_play_video(String(action["video"]), bool(action.get("skippable", 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", []))
|
|
# `--focus=` wins over the authored initial focus, and it did NOT before.
|
|
#
|
|
# 🔴 The flag parsed, was stored, and was applied to `view.focused_id` at
|
|
# startup -- and then this line overwrote it on every `_menu_enter`. So on
|
|
# the `--menu` path `--focus=` did nothing at all, silently: the run logged
|
|
# `focus ptbtn01` whatever was asked for.
|
|
#
|
|
# That mattered because it made an oracle capture untestable.
|
|
# `live-main-menu-options-focused.png` is the menu with OPTIONS focused --
|
|
# the only capture in the corpus of a MEASURED focus state, where the
|
|
# harness's own `main_menu` row uses an AUTHORED initial focus standing in
|
|
# for a measurement that says initial focus is unstable (HANDOFF Q5). There
|
|
# was no way to ask the port for the state the capture shows.
|
|
#
|
|
# It is pushed into the MENU MODEL, not just the view, so that navigation
|
|
# continues from where it was forced rather than jumping back on the first
|
|
# press.
|
|
if _force_focus != "" and not _menu.stack.is_empty():
|
|
var buttons: Array = view.screen.get("buttons", [])
|
|
if buttons.has(_force_focus):
|
|
_menu.stack[_menu.stack.size() - 1]["focus"] = _force_focus
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
# MEASURED, and this comment is a correction of itself. It read "AUTHORED, and
|
|
# the weakest thing in P6: HANDOFF Q10 says nothing on the disc [refuted] names
|
|
# which track a menu plays, so `authored/audio.json` picks one" -- which was
|
|
# true when written and was refuted the same week. `BGM_103` is measured from
|
|
# three independent legs: the phase handler `sub_821C5580` plays cue 1103,
|
|
# the bank's two waves are byte-for-byte what the XMA probe saw at the menu,
|
|
# and the disc census agrees. `authored/audio.json` CITES it; it does not
|
|
# choose it.
|
|
#
|
|
# 🔴 Left visible rather than swapped out, because this is the third instance
|
|
# of the drifted-comment trap in this project -- a correction lands in the
|
|
# code or the data and the sentence above it keeps describing the old world.
|
|
# Neither agent's checker looks at prose that contradicts the code under it.
|
|
#
|
|
# What is still true: the bed starts when the menu becomes live and CARRIES
|
|
# ACROSS submenus -- `play_bed` is idempotent, because music that restarts
|
|
# every time you press (B) is the kind of wrong that reads as "the audio
|
|
# works".
|
|
audio.play_bed("main_menu")
|
|
print(" menu on %s, focus %s" % [name, _focus_label(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:
|
|
# 🔴 THE RATE REPORT WAS BOOT-ONLY ON ITS FIRST VERSION, AND SAID SO NOWHERE.
|
|
# `_advance` walks the boot; the MENU arrives through here, so `--menu` --
|
|
# the mode a human actually spends time in, and the one where a slow frame is
|
|
# felt as input lag rather than seen as a coarse fade -- reported nothing. An
|
|
# instrument covering half the application while its own page claimed "every
|
|
# boot" is the shape this port keeps finding in other people's work.
|
|
if view != null:
|
|
var line := _rate_report(String(view.screen.get("name", "?")))
|
|
if line != "":
|
|
print(line)
|
|
_rate_reset()
|
|
# The plate goes with the screen it was measured on. See `_drop_overlay`.
|
|
_drop_overlay()
|
|
var action: Dictionary = _pending
|
|
_pending = null
|
|
var name := String(action["goto"])
|
|
view.holding = true
|
|
view.time_units = 0.0
|
|
view.looping_focus = _looping_for(name)
|
|
view.loop_phase_units = _loop_phase
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
var lc: Dictionary = _leaf_clock.get(name, {})
|
|
view.leaf_start_units = float(lc.get("start_units", -1.0)) if lc.get("start_units") != null else -1.0
|
|
view.leaf_rate = float(lc.get("rate", -1.0)) if lc.get("rate") != null else -1.0
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
# 🔴 THE PLATE COMES BACK IN THE GAME, AND IT DID NOT HERE.
|
|
#
|
|
# `_drop_overlay()` at the top of this function is right -- the plate goes
|
|
# with the screen it was measured on -- but nothing ever put it back, so Ⓑ
|
|
# from the main menu landed on a BARE title. `_overlay_spec` is cleared the
|
|
# moment the overlay is raised, and only the boot sequence ever sets it.
|
|
#
|
|
# MEASURED by the Decoder 2026-08-30 (branch `auto/no-disc-and-menu-captures`,
|
|
# `docs/re/data/nav-autorepeat-and-settled-b.txt`): after Ⓑ from the menu the
|
|
# plate IS re-drawn -- pressed at 351.2 s, its pulse back at 358.5 s.
|
|
#
|
|
# ⚠️ No new constant. The delay is not authored here and must not be: the
|
|
# overlay declaration is read back out of `authored/flow.json`'s boot step
|
|
# for this screen, so the plate re-appears by the SAME path, with the same
|
|
# shared clock, as it does on boot. Whatever the boot does, the return does.
|
|
_rearm_overlay_for(name)
|
|
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.set_focus(String(action["restore_focus"]))
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
print(" menu on %s, focus restored to %s" % [name, _focus_label(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
|
|
|
|
|
|
## How a focus reads in the log. The title has no focusable item at all -- it is
|
|
## a screen with no `buttons` that still takes (A) -- and an empty string there
|
|
## printed as a line that trailed off, which reads like the value went missing
|
|
## rather than like there is none.
|
|
static func _focus_label(id: String) -> String:
|
|
return id if id != "" else "(none -- this screen has no focusable item)"
|
|
|
|
|
|
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()
|
|
# The EFFECTIVE configuration, not the requested one: every free-running
|
|
# clock states whether it was pinned. Three of them exist (looping focus
|
|
# record, spin, leaf) and a run that pinned two of three used to look
|
|
# identical to one that pinned all three.
|
|
var pins := PackedStringArray()
|
|
pins.append("frozen" if view.frozen else "running")
|
|
pins.append("loop-phase=%s" % ("free" if view.loop_phase_units < 0.0 else str(view.loop_phase_units)))
|
|
pins.append("leaf=%s" % ("free" if view.leaf_time_units < 0.0 else str(view.leaf_time_units)))
|
|
# 🔴 THE OVERLAY IS A SECOND ScreenView WITH ITS OWN PINS, and this line
|
|
# reported only the first. An unpinned overlay would have been announced as
|
|
# pinned, because the announcement read `view.*` while the plate -- which
|
|
# carries a looping focus record, exactly the clock in question -- draws from
|
|
# `overlay.*`. An announcement that cannot distinguish the cases it announces
|
|
# is only half a guard.
|
|
if overlay != null:
|
|
pins.append("overlay(loop-phase=%s, leaf=%s)" % [
|
|
"free" if overlay.loop_phase_units < 0.0 else str(overlay.loop_phase_units),
|
|
"free" if overlay.leaf_time_units < 0.0 else str(overlay.leaf_time_units)])
|
|
print("t = %.2f units (%.3f s), pose = %s [%s]" % [
|
|
view.time_units, view.time_units / view.units_per_second,
|
|
"rest" if view.pose_mode == ScreenView.Pose.REST else "timeline",
|
|
", ".join(pins)])
|
|
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)])
|
|
# The overlay is a second build in the same frame, so it needs its own line.
|
|
# Folding its elements into the list above would make the capture report a
|
|
# screen that does not exist; leaving it out entirely made the first
|
|
# composited capture read as though the plate had not been drawn at all.
|
|
if overlay != null:
|
|
print("overlay %s at t = %.2f units (%.3f s), drew %d: %s" % [
|
|
overlay.screen.get("name", "?"), overlay.time_units,
|
|
overlay.time_units / overlay.units_per_second,
|
|
overlay.drawn.size(), ", ".join(overlay.drawn)])
|
|
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.
|
|
##
|
|
## 🔴 A FRAME INDEX IS NOT A CLOCK, AND THIS TOOL WAS BEING READ AS ONE.
|
|
##
|
|
## `_film_next += _film_interval` schedules frame `n` for `n * interval`. When a
|
|
## frame costs more than the interval -- and it does; one 1280x720 `save_png`
|
|
## under llvmpipe measured ~0.24 s, so any `--film-interval` under a quarter of
|
|
## a second is unreachable -- the deficit accumulates silently and every later
|
|
## frame is taken late by a growing amount. Asking for 0.05 s over 60 s
|
|
## requested 1 200 frames and produced **247**, an achieved 4.1 fps against a
|
|
## requested 20. Nothing in the output said so, and `f_071.png` still looks
|
|
## exactly like the frame that was meant to be 3.55 s in.
|
|
##
|
|
## `TEMPORAL-VERIFICATION.md` §1 is explicit that a capture that asked for one
|
|
## rate and delivered another "is not a slow capture, it is a **different**
|
|
## capture", and that an instrument which cannot report its own completeness may
|
|
## not be trusted (R3). So the film now carries its own timebase: one TSV row per
|
|
## frame with the elapsed second it was ACTUALLY taken at and both builds' clocks
|
|
## at that moment, appended as it goes so a run killed by `timeout` still leaves
|
|
## a complete index.
|
|
##
|
|
## The schedule is deliberately NOT rebased onto `_elapsed`. Catching up would
|
|
## hide the shortfall, which is the defect; falling behind and saying so is the
|
|
## fix. The achieved rate is printed every 40 frames and the row is the record.
|
|
func _film_capture() -> void:
|
|
var index_path := "%s_frames.tsv" % _film
|
|
var index := FileAccess.open(index_path, FileAccess.WRITE)
|
|
if index == null:
|
|
push_error("cannot write %s (%d)" % [index_path, FileAccess.get_open_error()])
|
|
return
|
|
index.store_line("frame\telapsed_s\trequested_s\tlag_s\tscreen\tview_units\toverlay_units")
|
|
print("film: %s, requested one frame every %.3f s; index -> %s"
|
|
% [_film, _film_interval, index_path])
|
|
while true:
|
|
await RenderingServer.frame_post_draw
|
|
if _elapsed >= _film_next:
|
|
var at := _elapsed
|
|
var img := viewport.get_texture().get_image()
|
|
img.save_png("%s_%03d.png" % [_film, _film_frame])
|
|
# The overlay's clock, or -1 where there is no second build. A blank
|
|
# would read as zero, and zero is a real instant on that timeline.
|
|
var over := -1.0
|
|
if overlay != null:
|
|
over = overlay.time_units
|
|
index.store_line("%d\t%.4f\t%.4f\t%.4f\t%s\t%.3f\t%.3f" % [
|
|
_film_frame, at, _film_next, at - _film_next,
|
|
String(view.screen.get("name", "?")) if view != null else "?",
|
|
view.time_units if view != null else -1.0, over])
|
|
index.flush()
|
|
_film_frame += 1
|
|
_film_next += _film_interval
|
|
if _film_frame % 40 == 0 and at > 0.0:
|
|
print("film: %d frames in %.2f s -- achieved %.2f fps against a requested %.2f"
|
|
% [_film_frame, at, _film_frame / at, 1.0 / _film_interval])
|
|
|
|
|
|
# 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.begins_with("wait:"):
|
|
# `wait:30` holds for thirty SECONDS of wall clock.
|
|
#
|
|
# Added because the port could not be asked to run for a stated
|
|
# duration at all: a bare `wait` is a no-op that returns as soon as
|
|
# the screen settles, so NOTHING that happens after the settle point
|
|
# was observable from a script. The music bed's loop is the case that
|
|
# exposed it -- an 87.7 s track whose restart nobody has ever
|
|
# watched, on a harness whose longest menu run was under seven
|
|
# seconds.
|
|
#
|
|
# It is wall clock rather than keyframe units on purpose: what it
|
|
# exists to observe are things on the AUDIO clock and the engine's,
|
|
# which are not the disc's and do not scale with `units_per_second`.
|
|
var secs := float(token.substr(5))
|
|
if secs <= 0.0:
|
|
push_error("--script: wait: needs a positive number of seconds, got %s" % token)
|
|
get_tree().quit(2)
|
|
return
|
|
print("script[%d] wait %.1f s" % [i + 1, secs])
|
|
# 🔴 WALL CLOCK, POLLED -- **not** `create_timer`, which was the
|
|
# first implementation and was wrong by 39 %.
|
|
#
|
|
# `create_timer` counts down on the frame delta. In an IDLE scene
|
|
# this container throttles hard and the delta it reports is not the
|
|
# time that passed, so a requested 30 s took **41.7 s** of real
|
|
# time while the port cheerfully reported 30. Measured against
|
|
# `date` either side of the process, with a no-wait control to
|
|
# subtract the 1.21 s of startup.
|
|
#
|
|
# ⚠️ That is idle-specific and NOT a general clock problem: over a
|
|
# whole boot, where things are animating, the port's own clock
|
|
# tracks wall clock to within 4 % (10.43 s wall against 10.82 s
|
|
# reported). The port's ANIMATION timing is fine. It is the waiting
|
|
# that was not.
|
|
#
|
|
# This matters because the only reason to hold a screen is to
|
|
# observe something on a REAL clock -- an audio loop, a timeout --
|
|
# and a timer that silently runs 39 % long would put every such
|
|
# observation at the wrong instant.
|
|
var until := Time.get_ticks_msec() + int(secs * 1000.0)
|
|
while Time.get_ticks_msec() < until:
|
|
await get_tree().process_frame
|
|
elif token == "wait":
|
|
pass
|
|
elif SCRIPT_ACTIONS.has(token):
|
|
# The elapsed clock goes in the line because a press with no timestamp
|
|
# cannot be compared against a MEASURED latency. The Decoder's Ⓑ
|
|
# figures are press-to-effect times; without this the port's own
|
|
# press time had to be guessed from the surrounding lines.
|
|
print("script[%d] %s at %.2f s" % [i + 1, token, _elapsed])
|
|
_press(String(SCRIPT_ACTIONS[token]))
|
|
else:
|
|
push_error("--script: no such step %s (have %s, wait, wait:<seconds>)"
|
|
% [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(), _focus_label(view.focused_id)])
|
|
# The frame worth having from a scripted run is the one the script ARRIVED
|
|
# at, not the one it started from. See the note beside the early capture.
|
|
if _capture_to != "":
|
|
await _capture(_capture_to)
|
|
# 🔴 `--linger=SECONDS` KEEPS A SCRIPTED RUN ALIVE AFTER THE WALK SETTLES.
|
|
#
|
|
# Without it a scripted run quits the moment the last step settles, and
|
|
# ANYTHING THE SCREEN DOES AFTERWARDS IS UNOBSERVABLE. That is not
|
|
# hypothetical: the plate on a returned title arrives on its own declared
|
|
# ramp at t=214..236, which is ~3.6 s after the screen settles, and a film of
|
|
# `--script=cancel` stopped at 168 units and never reached it. The behaviour
|
|
# was inferred from code-path identity because the harness could not watch it.
|
|
#
|
|
# `_script_settled` waiting for a hold is right -- a shot taken mid-fade is a
|
|
# photograph of a fade. This does not change that. It changes what happens
|
|
# after the shot, which was "exit" and is now optionally "keep running".
|
|
var linger := float(_args().get("linger", "0"))
|
|
if linger > 0.0:
|
|
print("linger: holding %.2f s past the walk so late arrivals are observable" % linger)
|
|
await get_tree().create_timer(linger).timeout
|
|
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
|
|
var playhead := -1.0
|
|
while _pending != null or _player != null or not view.holding \
|
|
or view.time_units < view.settle_time():
|
|
# A movie is not a screen that failed to settle: `S00A` runs 93.9 s and
|
|
# would trip a 20 s timeout every time (P7). But "wait as long as it
|
|
# takes" would turn a movie stuck at frame 0 into a job that hangs
|
|
# forever, which is the worse failure -- it does not fail, it waits.
|
|
#
|
|
# So the test is LIVENESS, not duration: while the playhead advances the
|
|
# deadline moves with it, and a stalled movie still trips the same 20 s.
|
|
if _player != null:
|
|
var now := _player.get_stream_position()
|
|
if now > playhead:
|
|
playhead = now
|
|
deadline = _elapsed + SCRIPT_STEP_TIMEOUT
|
|
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(), _focus_label(view.focused_id)])
|
|
|
|
|
|
# ── Recording the master bus ──────────────────────────────────────────────────
|
|
#
|
|
# `docs/port/AUDIO-VERIFICATION.md` §2. This is what closes the loop that file
|
|
# opens: comparing an exported Ogg against the disc proves the ASSET is right and
|
|
# says nothing about whether the engine ever reached it. A WAV captured off the
|
|
# Master bus proves both, and needs no sound card to do it.
|
|
#
|
|
# It is saved in `_exit_tree` rather than beside each `quit()` because there are
|
|
# eight of those and the one that would get missed is an error path -- exactly
|
|
# the run whose audio somebody wants to look at.
|
|
|
|
var _record_to := ""
|
|
var _record: AudioEffectRecord = null
|
|
|
|
|
|
func _start_recording() -> void:
|
|
var bus := AudioServer.get_bus_index("Master")
|
|
_record = AudioEffectRecord.new()
|
|
AudioServer.add_bus_effect(bus, _record)
|
|
_record.set_recording_active(true)
|
|
print("recording the Master bus to %s (audio driver: %s)" % [_record_to, MenuAudio.driver()])
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
# 🔴 boot.gd ALREADY HAD an `_exit_tree`, and adding a second was a parse
|
|
# error rather than a silent override -- the one failure mode that costs
|
|
# nothing. The mods report hangs off the existing hook.
|
|
_report_unused_mods()
|
|
if _record == null:
|
|
return
|
|
_record.set_recording_active(false)
|
|
var wav := _record.get_recording()
|
|
_record = null
|
|
if wav == null:
|
|
push_error("--audio: the Master bus recorded nothing at all")
|
|
return
|
|
# Write to a temp name and rename on completion, as everything else in this
|
|
# project does: another agent probing a file still being written gets a
|
|
# confident wrong duration rather than an error.
|
|
#
|
|
# ⚠️ The temp name ends in `.wav`, and that is not cosmetic. `save_to_wav`
|
|
# APPENDS `.wav` when the path does not already end in it, so `p6.wav.part`
|
|
# silently became `p6.wav.part.wav` -- and the rename below then failed to
|
|
# find its source and returned an error nobody read, leaving a run that
|
|
# printed success beside a file that was not there. This is the same bug the
|
|
# exporter's `run_ffmpeg` had in a different dialect: a temp-name convention
|
|
# must preserve the extension, because tools dispatch on it.
|
|
var tmp := _record_to + ".part.wav"
|
|
if wav.save_to_wav(tmp) != OK:
|
|
push_error("--audio: cannot write %s" % tmp)
|
|
return
|
|
var moved := DirAccess.rename_absolute(tmp, _record_to)
|
|
if moved != OK:
|
|
# Say so rather than print the success line below. A rename that fails
|
|
# quietly is worse than one that fails loudly: the caller measures a
|
|
# path that does not exist and reads "no such file" as "no audio".
|
|
push_error("--audio: wrote %s but could not rename it to %s (%d)"
|
|
% [tmp, _record_to, moved])
|
|
return
|
|
print("recorded %.3f s of Master bus -> %s (driver %s)"
|
|
% [float(wav.data.size()) / float(wav.mix_rate * 2 * (2 if wav.stereo else 1)),
|
|
_record_to, MenuAudio.driver()])
|
|
|
|
|
|
# ── The second build ─────────────────────────────────────────────────────────
|
|
|
|
## The overlay the current boot step owes, if it has not been raised yet.
|
|
var _overlay_spec: Dictionary = {}
|
|
## Where a STATIC overlay's own clock starts, and the main view's clock then.
|
|
var _overlay_t0 := 0.0
|
|
var _overlay_view_t0 := 0.0
|
|
## Wall-clock second at which it is raised, measured from the screen's settle.
|
|
var _overlay_due: float = 0.0
|
|
## True only for `--overlay=` WITHOUT `--boot` -- the static diagnostic.
|
|
##
|
|
## It replaces `_sequence.is_empty()`, which was standing in for "diagnostic
|
|
## mode" and silently caught `--menu` too, because `_sequence` is filled only by
|
|
## `--boot`. The consequence was visible and nobody had looked: on the menu's
|
|
## return to the title the plate was posed at its settle in a single frame
|
|
## instead of fading across its declared 214->236.
|
|
var _static_overlay := false
|
|
## `--loop-phase=<units>` pins the looping record's phase. Negative is
|
|
## free-running, which is the default and what a player gets. Only the
|
|
## regression harness passes it -- see `ScreenView.loop_phase_units`.
|
|
var _loop_phase: float = -1.0
|
|
|
|
|
|
func _overlay_process(delta: float) -> void:
|
|
if overlay != null:
|
|
# ONE CLOCK. Not `+= delta * ups` on each independently: they would drift
|
|
# apart by a frame here and there, and the whole content of the finding
|
|
# is that the 120 units between build 4's last ramp and the plate's
|
|
# `a=255` is a fixed interval on a shared timeline.
|
|
# 🔴 A STATIC overlay poses at ITS OWN ARRIVAL, not at the shared clock.
|
|
#
|
|
# `--screen=X --overlay=Y` has no sequence driving it, so `view` sits at
|
|
# its settle instant while this line pushed the *raw elapsed* clock into
|
|
# the overlay -- 9 units at the moment `--capture` fires. For
|
|
# `press_start` that is alpha 0 (transparent until t=214, opaque only at
|
|
# t=236-238), so the one flag whose purpose is "put the plate on the
|
|
# title" drew NOTHING and reported `drew 0`. It read as a title with no
|
|
# plate, which is what anyone would conclude.
|
|
#
|
|
# It cost a measurement: against `live-title-press-a.png` every sweep
|
|
# phase gave a flat ~1.0 % floor, and the residual was a row of
|
|
# glyph-sized blobs on the plate's own position. Posed properly the same
|
|
# comparison is 0.00093 %.
|
|
#
|
|
# In a `--boot` sequence the shared clock is the whole point -- the 120
|
|
# units between build 4's last ramp and the plate's a=255 is a fixed
|
|
# interval on ONE timeline -- so that path is untouched.
|
|
if _static_overlay:
|
|
# 🔴 GATED ON THE DIAGNOSTIC FLAG, NOT ON `_sequence.is_empty()`.
|
|
# `_sequence` is filled only by `--boot`, so the old test was true for
|
|
# `--menu` as well and the menu's return-to-title took this branch:
|
|
# the plate was posed at its settle in ONE frame (overlay clock 0 ->
|
|
# 244.67) instead of fading across its declared 214->236. The call
|
|
# site's own `why` claimed "whatever the boot does, the return does",
|
|
# and it did the opposite.
|
|
#
|
|
# 🔴 A static overlay STARTS at its arrival and then ADVANCES. It used
|
|
# to be pinned there on every frame, which was this fix overshooting.
|
|
#
|
|
# Pinning fixed the original defect -- `--screen=X --overlay=Y` pushed
|
|
# the raw elapsed clock in, 9 units at capture, and `press_start` drew
|
|
# nothing -- but it replaced a frozen-too-early overlay with a
|
|
# frozen-at-arrival one. `--screen=X` animates X; freezing Y while
|
|
# animating X is an inconsistency in one command, and the plate pulse
|
|
# is what made it visible: the plate oscillates on the boot path and
|
|
# sat flat here, which reads as a regression and is not one.
|
|
#
|
|
# Offset, not pinned: the overlay begins at its own settle and takes
|
|
# the same delta the main view takes.
|
|
overlay.time_units = _overlay_t0 + (view.time_units - _overlay_view_t0)
|
|
else:
|
|
overlay.time_units = view.time_units
|
|
overlay.queue_redraw()
|
|
if _overlay_quit_at >= 0.0 and _elapsed >= _overlay_quit_at:
|
|
print("boot ends on %s + %s at %.2f s"
|
|
% [view.screen.get("name", "?"), overlay.screen.get("name", "?"), _elapsed])
|
|
_overlay_quit_at = -1.0
|
|
_finish_boot()
|
|
return
|
|
if _overlay_spec.is_empty() or _elapsed < _overlay_due:
|
|
return
|
|
_raise_overlay(String(_overlay_spec.get("screen", "")))
|
|
|
|
|
|
## Re-arm the overlay a screen declares in the authored boot sequence.
|
|
##
|
|
## Used when the MENU arrives at a screen, not just when the boot walks onto it.
|
|
## It looks the declaration up rather than naming `press_start`, so a screen that
|
|
## gains an overlay in `authored/flow.json` gets it on both paths at once and
|
|
## this function needs no edit.
|
|
func _rearm_overlay_for(name: String) -> void:
|
|
if _flow == null or not (_flow as Dictionary).has("boot"):
|
|
return
|
|
for step: Dictionary in _flow["boot"]:
|
|
if String(step.get("screen", "")) != name:
|
|
continue
|
|
var spec: Variant = step.get("overlay", null)
|
|
if typeof(spec) == TYPE_DICTIONARY:
|
|
_overlay_spec = spec
|
|
_overlay_due = _elapsed
|
|
_overlay_process(0.0)
|
|
return
|
|
|
|
|
|
## Composite a second build over the first.
|
|
##
|
|
## It starts at `time_units = 0` and plays its OWN group, so the plate rises and
|
|
## fades in exactly as the disc declares -- alpha 0x00 at t=214, 0xff by t=238 --
|
|
## rather than appearing as a cut. `holding` then parks it at its settle, which
|
|
## for this build is the visible pose.
|
|
##
|
|
## ⚠️ It does NOT pulse, and that is a decision with arithmetic behind it rather
|
|
## than an omission. See `authored/flow.json`, `no_pulse_why`.
|
|
func _raise_overlay(name: String) -> void:
|
|
var spec := _overlay_spec
|
|
_overlay_spec = {}
|
|
if name == "":
|
|
return
|
|
overlay = ScreenView.new()
|
|
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.loop_phase_units = _loop_phase
|
|
# The overlay's LEAF pin was missing while its loop-phase pin was wired, so a
|
|
# run requesting both had one reach this view and one not. Found by extending
|
|
# the announcement to cover the overlay -- the guard's first use.
|
|
overlay.leaf_time_units = view.leaf_time_units
|
|
overlay.draw_leaf_for = _draw_leaf_for
|
|
# 🔴 THE OVERLAY IS A SECOND ScreenView AND IT WAS NOT GETTING THIS. Every
|
|
# other decoded rule on this page is assigned to `view` AND to `overlay`, and
|
|
# `additive_elements` was assigned to `view` in three places and to `overlay`
|
|
# in none -- so the PRESS (A) plate, which is an overlay, kept drawing its
|
|
# focus record alpha-over after the blend was wired in. The change reported
|
|
# EXACTLY ZERO against the capture, which is what a change that never reached
|
|
# the renderer looks like.
|
|
overlay.loop_leaf = _loop_leaf_screens.has(name)
|
|
overlay.holding = true
|
|
overlay.time_units = 0.0
|
|
if not overlay.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
overlay.queue_free()
|
|
overlay = null
|
|
return
|
|
# After `view`, so it draws over it: Node2D siblings paint in tree order and
|
|
# the export's own `paint_order` only orders WITHIN a build.
|
|
viewport.add_child(overlay)
|
|
_overlay_t0 = overlay.settle_time()
|
|
_overlay_view_t0 = view.time_units
|
|
print(" overlay %s raised at %.2f s, %d element(s), settles at t=%d"
|
|
% [name, _elapsed, overlay.screen.get("elements", []).size(), int(overlay.settle_time())])
|
|
# A boot with no menu to hand over to has now finished: it was held open for
|
|
# this. Give the plate its own group time to play before leaving, so the
|
|
# artifact shows the composited state rather than the frame it began on.
|
|
var visible_at := overlay.settle_time() / overlay.units_per_second
|
|
# `--screen --overlay=` uses the same code path as a fast check and must not
|
|
# narrate a sequence it is not running: a log line that lies is worse than
|
|
# no log line.
|
|
if _sequence.is_empty():
|
|
return
|
|
# %d - 118, computed rather than spelled: the literal "120" here disagreed
|
|
# with the "t=236" printed beside it in the same sentence, for weeks.
|
|
print(" plate reaches full alpha at t=%d (%.2f s on the shared clock), \
|
|
%d units after build 4's effect quads end together at t=118"
|
|
% [int(overlay.settle_time()), visible_at, int(overlay.settle_time()) - 118])
|
|
if not _play and _film == "":
|
|
# The LATER of the two, not the overlay's alone.
|
|
#
|
|
# 🔴 THE REASON GIVEN HERE WAS BACKWARDS AND IS REPLACED. It said build 4
|
|
# "is still fading up from black until t=261 -- its `pteff00` quad is
|
|
# 7 % opaque at 243". `pteff00` is the screen's black veil and it does
|
|
# BOTH fades: opaque at t=0, clear by **t=16**, transparent all the way
|
|
# to t=261, then back to opaque by t=269. At t=243 it is 0 % opaque, not
|
|
# 7 %, and 261..269 is the fade-OUT. So build 4 finishes arriving 16
|
|
# units in, not 261.
|
|
#
|
|
# The line still stands, for the reason underneath it rather than the
|
|
# one that was written: build 4's own last hold is t=160 (`ptcopyright`
|
|
# reaching full alpha) and the plate's is t=236, so ending on the
|
|
# overlay alone would still be ending on the earlier of two clocks. The
|
|
# darker capture that prompted this was real; the explanation was not.
|
|
var ends_at := maxf(view.settle_time(), overlay.settle_time()) / view.units_per_second
|
|
_overlay_quit_at = _elapsed - (view.time_units / view.units_per_second) + ends_at
|
|
print(" boot ends at %.2f s, once both builds have arrived (t=%d)"
|
|
% [_overlay_quit_at, int(maxf(view.settle_time(), overlay.settle_time()))])
|
|
# `spec` is read only for the log; the reasoning lives in flow.json where a
|
|
# reader looking for a decision will find it.
|
|
if spec.has("why"):
|
|
print(" why: %s" % String(spec["why"]).substr(0, 96))
|
|
|
|
|
|
var _overlay_quit_at: float = -1.0
|
|
|
|
|
|
## Take the overlay away with the screen it belongs to.
|
|
##
|
|
## The plate was measured on the BOOT title only. Whether it is there when the
|
|
## title is reached again -- (B) from the main menu, or after the attract movie
|
|
## -- is not measured, so leaving it up would be claiming something nobody has
|
|
## watched. `authored/flow.json` says the same thing in the step's `scope_why`.
|
|
func _drop_overlay() -> void:
|
|
_overlay_spec = {}
|
|
_overlay_quit_at = -1.0
|
|
if overlay != null:
|
|
overlay.queue_free()
|
|
overlay = null
|
|
|
|
|
|
## End a `--boot` run, photographing the composited end state first if asked.
|
|
##
|
|
## `--capture` used to be a `--screen`-only flag, taken in `_ready`. The boot had
|
|
## no artifact of its own except a whole `--film` filmstrip, which is 600+ PNGs
|
|
## to answer one question: is the plate on top of the title at the end. This
|
|
## takes that one frame.
|
|
func _finish_boot() -> void:
|
|
if _capture_to != "":
|
|
await _capture(_capture_to)
|
|
get_tree().quit(0)
|
|
|
|
|
|
var _capture_to := ""
|
|
|
|
|
|
## The authored looping-focus entries that apply to one screen.
|
|
##
|
|
## The table is keyed `<screen>/<element>` 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
|
|
|
|
|
|
## Extra hold for one screen, in units, from `authored/flow.json` `dwell`.
|
|
##
|
|
## Zero unless a measurement is authored. A value here is an ADDITION to the
|
|
## screen's own declared group, not a replacement for it.
|
|
func _dwell_for(screen_name: String) -> float:
|
|
if _flow == null or not (_flow is Dictionary):
|
|
return 0.0
|
|
var table: Variant = (_flow as Dictionary).get("dwell", {})
|
|
if not (table is Dictionary):
|
|
return 0.0
|
|
var v: Variant = (table as Dictionary).get(screen_name, 0.0)
|
|
return float(v) if (v is float or v is int) else 0.0
|
|
|
|
|
|
## Check the authored values this port CANNOT act on, and fail loudly if one
|
|
## changes.
|
|
##
|
|
## `left_right`, `input_during_transition` and `ramp` are authored with reasons
|
|
## and read by nothing -- the behaviour they describe is hardcoded. That is
|
|
## defensible for a record and dangerous for a switch, and they are written like
|
|
## switches: someone setting `left_right` to "move" would change nothing and get
|
|
## no warning.
|
|
##
|
|
## So rather than invent the missing implementations, the port ASSERTS the value
|
|
## it was built against. Changing one now produces an error naming the file
|
|
## instead of silence, which is the distinction the `why` for `left_right`
|
|
## claims to be making -- "the game ignores it" and "we never wired it" are
|
|
## different lines of code -- and which was not actually being made.
|
|
func _check_authored_invariants(timing: Dictionary) -> void:
|
|
var nav: Variant = (_flow as Dictionary).get("navigation", {}) if _flow is Dictionary else {}
|
|
if nav is Dictionary:
|
|
var lr := String((nav as Dictionary).get("left_right", "nothing"))
|
|
if lr != "nothing":
|
|
push_error("authored/flow.json navigation.left_right is \"%s\"; this port implements only \"nothing\"" % lr)
|
|
var idt := String((nav as Dictionary).get("input_during_transition", "ignored"))
|
|
if idt != "ignored":
|
|
push_error("authored/flow.json navigation.input_during_transition is \"%s\"; this port implements only \"ignored\"" % idt)
|
|
var ramp := String(timing.get("ramp", "linear"))
|
|
if ramp != "linear":
|
|
push_error("authored/timing.json ramp is \"%s\"; ScreenView interpolates linearly and has no other mode" % ramp)
|