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)