port: add wait:<seconds>, observe the bed's loop, and measure the seam at 3.4 s of silence

The port could not be asked to run for a stated duration -- a bare  step
is a no-op that returns at settle -- so nothing after the settle point was
observable. An 87.7 s bed on a harness whose longest menu run was 7 s.

The bed loops at 87.8 s against the track's 87.7 (r=0.947 and 0.885 on a clean
bed-only recording): loop: restart behaves exactly as authored. First
end-to-end observation of P6 looping.

The authored 'audibly wrong at the seam' is confirmed and quantified: 36
consecutive near-silent 50 ms windows, 84.40-87.80 s, about 3.4 s of silence
after a fade from RMS 2057 to 431. Recorded in authored/audio.json. It does NOT
license trimming, which would still invent a loop point.

My first wait: used create_timer and ran 39% long (30 s requested, 41.7 s wall)
because an idle scene throttles the delta it counts down on. Now polls
Time.get_ticks_msec: +4.6%. Checked before generalising: over a boot the port's
clock tracks wall clock within 4%, so animation timing is sound and the earlier
splash-dwell agreement stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 23:23:35 +00:00
parent b71c35acf4
commit e5398be93b
3 changed files with 120 additions and 3 deletions

View File

@@ -103,7 +103,7 @@
"loop": "restart",
"kind": "measured",
"why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed.",
"loop_why": "AUTHORED, and it will be AUDIBLY WRONG AT THE SEAM. HANDOFF Q10: no loop-point field has been identified anywhere, so the file carries no marked restart. `restart` replays from sample 0, which means the listener hears the track's own fade-out and the silence after it before the music comes back. Trimming to the fade would sound better and would be worse: it would INVENT a loop point, and an invented one is indistinguishable from a decoded one a month later. The seam stays ugly until a loop point is measured or a capture of the real menu looping settles it.",
"loop_why": "AUTHORED, and it will be AUDIBLY WRONG AT THE SEAM. HANDOFF Q10: no loop-point field has been identified anywhere, so the file carries no marked restart. `restart` replays from sample 0, which means the listener hears the track's own fade-out and the silence after it before the music comes back. Trimming to the fade would sound better and would be worse: it would INVENT a loop point, and an invented one is indistinguishable from a decoded one a month later. The seam stays ugly until a loop point is measured or a capture of the real menu looping settles it. ⚠️ MEASURED 2026-08-29, and it is worse than 'ugly': the bed loops at 87.8 s against the track's own 87.7 s, and the gap before the music returns is about 3.4 SECONDS of near-silence (36 consecutive 50 ms windows below peak 300, spanning 84.40-87.80 s, after a fade from RMS 2057 to 431). Recorded off the port's own Master bus, so it is what a player hears, not a property of the file. That is long enough to read as the music having stopped rather than looped. The number is here to make the cost of the missing loop point concrete; it does NOT license trimming to the fade, which would still invent a loop point. See docs/port/DECISIONS.md.",
"stems": "sum",
"stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled."
}

View File

@@ -5681,3 +5681,76 @@ first where I would have invented an *anomaly* rather than a defect.
Nothing in this section rests on that correlator. The finding is `stop_bed` having
no caller, which is a fact about the source.
## `wait:<seconds>`, and the bed's loop seam is 3.4 seconds of silence
The port could not be asked to **run for a stated duration**. A bare `wait`
script step is `pass` — it returns as soon as the screen settles — so nothing
happening after the settle point was observable from a harness at all. The music
bed made that concrete: an 87.7 s track whose restart nobody had watched, on a
harness whose longest menu run was under seven seconds.
`--script=wait:105` fixes that, and the first thing it found was the answer.
### The bed loops, exactly where it should
Recording the Master bus over 132 s with nothing but the menu playing — no movie,
no voice, a clean signal — the bed's `t=2…17 s` template matches twice:
| | r | pass begins |
|---|---|---|
| first pass | **0.947** | 0.0 s |
| second pass | **0.885** | **87.8 s** |
The track is **87.7 s**. So `loop: "restart"` does what `authored/audio.json`
says: replays from sample 0 at the track's end, no trimming, no loop point.
✅ First end-to-end observation of P6's looping behaviour.
### And the seam is measurably as bad as it was authored to be
`loop_why` predicted *"the listener hears the track's own fade-out and the silence
after it before the music comes back"*. Measured off the bus:
| window around the seam | RMS |
|---|---|
| 8 … 4 s | 2057 |
| 4 … 2 s | 714 |
| 2 … 0.3 s | 431 |
| +0.3 … +2 s | 2164 |
and **36 consecutive 50 ms windows below peak 300, from 84.40 s to 87.80 s**
about **3.4 seconds of near-silence** before the music returns. That is long
enough to read as *the music stopped*, not as a loop.
The claim was right and is now a number. ⚠️ It does **not** license trimming to
the fade: that would still invent a loop point, and an invented one is
indistinguishable from a decoded one a month later. The measurement is recorded
to make the cost of the missing loop point concrete, in `authored/audio.json`.
### 🔴 My first `wait:` was wrong by 39 %, and the way it was wrong matters
It used `create_timer`, which counts down on the frame delta. In an **idle**
scene this container throttles and the delta it reports is not the time that
passed: a requested 30 s took **41.7 s** of wall clock while the port reported
30. Measured against `date` either side of the process, with a no-wait control
to subtract 1.21 s of startup.
Now polled on `Time.get_ticks_msec()`: 30 s requested, **31.38 s** wall, +4.6 %.
⚠️ **This is idle-specific and is not a general clock fault**, which I checked
before writing any of it down. Over a whole boot, where things are animating, the
port's clock tracks wall clock to **within 4 %** — 10.43 s wall against 10.82 s
reported. So the port's *animation* timing is sound and the earlier splash-dwell
agreement (4.400 s and 3.650 s against declared 255 and 210 units) **stands**; I
had briefly believed it did not.
What is genuinely unsound is `_elapsed` **while idle**: it reported 23.21 s across
30 real seconds of waiting. Every timing the port prints during animation is
fine; a timing that spans a wait is not.
📌 The reason to care: the only reason to hold a screen is to observe something on
a **real** clock — an audio loop, a timeout, an idle return. A timer that
silently ran 39 % long would have put every such observation at the wrong instant,
and the bed-loop result above would have been the first casualty. It survived only
because the seam was read off the **recording's** clock, which the bed's own known
length calibrates.

View File

@@ -772,13 +772,57 @@ func _run_script() -> void:
await _shoot("00_start")
for i in range(_script.size()):
var token := _script[i].strip_edges().to_lower()
if token == "wait":
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):
print("script[%d] %s" % [i + 1, token])
_press(String(SCRIPT_ACTIONS[token]))
else:
push_error("--script: no such step %s (have %s, wait)" % [token, ", ".join(SCRIPT_ACTIONS.keys())])
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.