From d949a2216f25b4d9668b30eb01872d05ace20539 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sun, 30 Aug 2026 10:03:52 +0000 Subject: [PATCH] re: the menu loop WATCHED -- three wraps, 61.81 s, and my placement is refuted Settles the conflict by timing the loop instead of converting it. A tailing probe stamps read_offset with the wall clock as each log line arrives, so the period needs no bits-to-time step -- the step already shown to be invalid. Three wraps, each exactly loop_end -> loop_start, and BOTH CONTEXTS WRAP AT THE SAME INSTANT all three times. That is the property two stems of one performance must have and the one the linear conversion could not deliver (62.34 vs 63.29 s would drift a second per cycle). Cycle 61.56 and 62.06 s, mean 61.81, against the audio autocorrelation's 61.93 -- 0.2 % apart from instruments sharing nothing. Linearity refuted a second time and internally: the fitted rate over 10..60 s is 341 394 bits/s while the cycle covers 22 034 741 bits in 61.81 s = 356 491 bits/s, 4.4 % apart inside one stream. My own audio locator's PLACEMENT is refuted. loop_start at 3.6 M bits is 11.6 % of the stream by any reading, ~10.1 s at the cycle's own mean rate, against the 0.25 s that page reported -- for the reason already suspected, that its control matched slices cut from the wave itself and never tested the aliasing the real problem has. The length was right and the span was wrong. Still not measured: loop_start in seconds. Offsets below it play exactly once and this trace stamped that whole stretch at t=0.002, swallowing the log backlog in one read, because it started after the music. The fix is to start the trace before tapping into the menu -- one line, not done. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- docs/re/data/menu-bgm-wrap-timing.txt | 36 +++++++++++ .../menu-bgm-loop-fields-conflict.md | 60 ++++++++++++++++-- tools/re-capture/xma_readoff_trace.py | 62 +++++++++++++++++++ 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 docs/re/data/menu-bgm-wrap-timing.txt create mode 100755 tools/re-capture/xma_readoff_trace.py diff --git a/docs/re/data/menu-bgm-wrap-timing.txt b/docs/re/data/menu-bgm-wrap-timing.txt new file mode 100644 index 00000000..5eb87f5c --- /dev/null +++ b/docs/re/data/menu-bgm-wrap-timing.txt @@ -0,0 +1,36 @@ +# The menu loop WATCHED, not converted: three wraps, timed by wall clock. +# +# 2026-08-30. tools/re-capture/xma_readoff_trace.py tails the Apu debug log +# and stamps each 'Looped Data' read_offset with the wall clock as it arrives. +# That gives the loop period in seconds with NO bits-to-time conversion -- +# which is the step menu-bgm-loop-fields-conflict.md showed to be invalid. +# +# WRAPS (each ctx from its own loop_end to its own loop_start): +# t= 65.07 ctx1 26,216,351 -> 3,542,945 ctx0 25,640,423 -> 3,610,911 +# t=126.63 ctx1 26,216,351 -> 3,542,945 ctx0 25,640,423 -> 3,610,911 +# t=188.69 ctx1 26,216,351 -> 3,542,945 ctx0 25,640,423 -> 3,610,911 +# +# BOTH CONTEXTS WRAP AT THE SAME INSTANT, three times. They stay +# sample-synchronous, which is what two stems of one performance must do and +# what the linear conversion's 62.34-vs-63.29 s could not deliver. +# +# CYCLE: 126.63-65.07 = 61.56 s ; 188.69-126.63 = 62.06 s ; mean 61.81 s. +# The audio measurement (menu-bgm-loop-measured.txt) gave 61.93 s from an +# autocorrelation that used no wave at all. 0.2 % apart, different instruments. +# +# πŸ”΄ LINEARITY REFUTED AGAIN, internally: the fitted rate over the clean +# stretch 10..60 s is 341,394 bits/s, while the cycle covers 22,034,741 bits +# in 61.81 s = 356,491 bits/s. 4.4 % apart within one stream, so bit offsets +# cannot be converted to seconds by any single rate. +# +# 🟑 loop_start's POSITION IN THE WAVE IS NOT DETERMINED BY THIS RUN. +# Offsets below loop_start (32 .. 3,605,682) are played exactly ONCE, before +# the first wrap, and this trace stamped that whole stretch at t=0.002 -- +# 616 samples spanning offsets 32..2,559,033 in one batch, because the trace +# started after the music did and read the log's backlog in one gulp. +# A linear back-extrapolation suggests ~9-13 s, but linearity is exactly what +# is refuted above, so that is an indication and not a measurement. +# +# WHAT IS SETTLED: the loop region begins WELL INTO the wave, not at ~0.25 s. +# 3.6 M bits is 11.6 % of the stream by any reading; at the cycle's own mean +# rate it is 10.1 s. The audio locator's placement is refuted. diff --git a/docs/re/structures/menu-bgm-loop-fields-conflict.md b/docs/re/structures/menu-bgm-loop-fields-conflict.md index 25157746..220c1d9f 100644 --- a/docs/re/structures/menu-bgm-loop-fields-conflict.md +++ b/docs/re/structures/menu-bgm-loop-fields-conflict.md @@ -72,9 +72,59 @@ So the honest position is: | where the cycle *starts* in the wave | πŸ”΄ **contested** β€” 0.25 s from audio, ~10 s from the field | | bits β†’ seconds | ❔ needs an XMA frame walk; not done | -## What the port should do: nothing yet +## βœ… RESOLVED (2026-08-30, later) β€” the wrap was watched, and the length is confirmed -Its shipped loop β€” trim to 61.930 s β€” is verified in its own output and has no seam. -**Do not re-author it from this page.** What would settle it is walking the XMA -frame headers to convert `loop_start`/`loop_end` honestly, and a hold long enough to -**watch a wrap** rather than infer it. Neither is done. +The conflict is settled by timing the loop instead of converting it. Tailing the Apu +debug log and stamping `input_buffer_read_offset` as it arrives +([`../data/menu-bgm-wrap-timing.txt`](../data/menu-bgm-wrap-timing.txt)): + +| | | +|---|---| +| wraps observed | **three** | +| each | exactly its own `loop_end` β†’ its own `loop_start` | +| the two contexts | wrap at the **same instant**, all three times | +| cycle | **61.56 s** and **62.06 s** β†’ **61.81 s** | + +βœ… **The field semantics are now watched, not inferred.** And the two stems wrapping +together is the property the linear conversion could not deliver β€” 62.34 vs 63.29 s +would have drifted them a second per cycle. + +βœ… **61.81 s against the audio measurement's 61.93 s** β€” 0.2 % apart, from +instruments sharing nothing: one is a wall clock between decoder events, the other +an autocorrelation that never touched the wave. + +πŸ”΄ **Linearity refuted a second time, internally.** The fitted rate over the clean +stretch 10–60 s is **341 394 bits/s**; the cycle covers 22 034 741 bits in 61.81 s += **356 491 bits/s**. **4.4 % apart inside one stream** β€” no single rate converts +these offsets. + +### πŸ”΄ And my audio locator's *placement* is refuted + +`loop_start` at 3.6 M bits is **11.6 % of the stream** by any reading, and ~10.1 s +at the cycle's own mean rate. [`menu-bgm-loop-measured.md`](menu-bgm-loop-measured.md) +put the loop's start at **0.25 s**. That is wrong, and the reason is the one already +suspected: its control matched slices cut from the wave itself, which never tested +the aliasing the real problem has. + +**So the length was right and the placement was wrong** β€” which is why the port's +shipped loop sounds correct: it has the right *duration*, over the wrong *span*. + +### 🟑 Where `loop_start` sits in seconds is still not measured + +Offsets below `loop_start` play **exactly once**, before the first wrap, and this +trace stamped that whole stretch at `t=0.002` β€” 616 samples spanning offsets +32…2 559 033 in a single batch, because the trace started after the music and +swallowed the log's backlog in one read. A back-extrapolation suggests ~9–13 s, but +it is a *linear* back-extrapolation and linearity is what the same run refutes. + +**The fix is one line of scheduling**: start the trace *before* tapping into the +menu, so the first pass is sampled at 0.5 s like every later cycle. Not done. + +## What the port should do: keep 61.93, and know the span is wrong + +βœ… **The 61.93 s length is now confirmed twice over** β€” keep it. ⚠️ **But the span +is wrong**: the game loops a 61.8 s window that begins ~10 s into the wave, not the +first 61.93 s. A trim to `[0, 61.93]` therefore replays the intro every cycle and +omits the tail the game does play. 🟑 The exact start is not measured, so **do not +re-cut on a guess** β€” what is needed is the trace started before the music, which is +one line of scheduling and is not done. diff --git a/tools/re-capture/xma_readoff_trace.py b/tools/re-capture/xma_readoff_trace.py new file mode 100755 index 00000000..672c237d --- /dev/null +++ b/tools/re-capture/xma_readoff_trace.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Timestamp the XMA read offset, so a loop can be TIMED instead of converted. + +`menu-bgm-loop-fields-conflict.md` leaves a conflict: the context's +`loop_start`/`loop_end` imply a cycle starting ~11.6 % into the wave, while an +audio locator put it at 0.25 s. Converting the bit offsets needs an XMA frame walk +β€” but the conflict can be settled without one. + +`UpdateLoopStatus` logs `input_buffer_read_offset` on every decoded frame. Xenia's +log lines carry no timestamp, so this tails the log and stamps each sample with the +wall clock as it arrives. That gives two things a bit offset alone cannot: + + * **the loop period in seconds**, from the wall time between wraps β€” no + conversion, no assumption about bits per second; + * **an empirical bitsβ†’time curve**, which is exactly what the linear assumption + got wrong (it produced 62.34 s and 63.29 s for two stems that must agree). + +⚠️ Stamping on arrival dates a sample by when its line was *read*, not emitted, so +absolute times carry the log's buffering. Differences between wraps β€” which is what +this is for β€” are unaffected as long as the buffering is stationary. + + xma_readoff_trace.py LOG OUT.tsv [seconds] +""" +import re +import sys +import time + +LOG, OUT = sys.argv[1], sys.argv[2] +DUR = float(sys.argv[3]) if len(sys.argv) > 3 else 260 +PAT = re.compile(rb"XmaContext (\d+): Looped Data: (\d+) < (\d+) \(Start: (\d+)\)") + +t0 = time.time() +off = 0 +out = open(OUT, "w") +out.write("# t_s\tctx\tread_offset\tloop_end\tloop_start\n") +last = {} +wraps = [] +while time.time() - t0 < DUR: + try: + with open(LOG, "rb") as f: + f.seek(off) + chunk = f.read() + off += len(chunk) + except FileNotFoundError: + time.sleep(0.5); continue + now = time.time() - t0 + for m in PAT.finditer(chunk): + c = int(m.group(1)); ro = int(m.group(2)) + out.write(f"{now:.3f}\t{c}\t{ro}\t{int(m.group(3))}\t{int(m.group(4))}\n") + if c in last and ro < last[c] - 1_000_000: + wraps.append((now, c, last[c], ro)) + print(f"[{now:7.1f}s] ctx{c} WRAP {last[c]:,} -> {ro:,}", flush=True) + last[c] = ro + out.flush() + time.sleep(0.5) +out.close() +print(f"wraps seen: {len(wraps)}", flush=True) +for c in sorted({w[1] for w in wraps}): + ts = [w[0] for w in wraps if w[1] == c] + if len(ts) > 1: + gaps = [round(ts[i+1]-ts[i], 3) for i in range(len(ts)-1)] + print(f" ctx{c}: wrap times {[round(t,2) for t in ts]} gaps {gaps}", flush=True)