to_xma_riffs now emits the leading headerless segment when it sits at a whole
number of XMA1 packets and carries a non-zero byte. VOICE_D_453 goes from a
0.14 s trailing fragment to a 45116-byte leading sub-wave that dominates it.
I withdrew this exact change earlier for two reasons. Both are now answered
rather than argued away:
* "It recovers no audio" -- it used the STEREO format. At two channels every
bank yields exactly 1792 bytes, one frame, whatever its size. Mono yields up
to 113x more.
* "It matches 1524 of 8021 RIFF-bearing entries" -- the byte-level reach is
still 1524, but the audible reach is not. Across the 84 movie-bound banks
the segment adds >1 s to exactly 7, the hokyu_*_H tankers on D_453/D_454 --
precisely the broken ones -- and <=0.25 s to 66 of the rest. The largest
non-resupply addition is S04A at +0.66 s on a 256 s movie.
The safety oracle is recorded with its limits: 8 of the 84 banks ALREADY
exceed their movie's duration before the change, by hundredths of a second,
so it cannot resolve differences at that scale. It establishes scoping, not
correctness. Callers clamp to the movie length regardless.
VOICE_D_451's all-zero leading region is skipped by the non-zero guard, so
the rule cannot prepend silence to a bank that does not need it. Pinned, as
is the packet arithmetic (n = 8, 1, 7, 22, 29) which has no tunable.
slb_disc, movie_subtitle_disc and movie_manifest_disc all still pass.
NOT verified by ear -- that needs a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The last open question was whether the leading XMA1-mono region duplicates the
RIFF sub-wave, which would make the earlier totals double-count. It does not.
Decoding both parts of every bank to mono PCM and measuring energy:
bank leading secs / RMS riff secs / RMS
VOICE_D_450 0.49 / 158 2.82 / 9898
VOICE_D_451 0.01 / 0 1.58 / 9128
VOICE_D_452 0.31 / 301 2.18 / 9061
VOICE_D_453 2.12 / 9770 0.14 / 14462
VOICE_D_454 3.07 / 10428 0.43 / 11639
Two shapes, and no bank holds the same content twice. In 450/451/452 the
leading region is silence or near-silence (RMS 0-301 against ~9000 for
speech) and the RIFF holds the line. In 453/454 the leading region holds the
line and the RIFF is a short loud tail fragment. Sequential segments of one
clip, so the totals stand and with them the 48 kHz fit.
This also closes the mystery that started the whole thread. The corpus
recorded 450 = 2.8 s, 451 = 1.6 s, 452 = 2.2 s as plausible but 453 = 0.14 s
and 454 = 0.43 s as "far too short". The decoder skips everything before the
first RIFF: for the first three that discards only silence, so they looked
fine; for the last two it discards the line itself and leaves the trailing
fragment. One rule, two outcomes, depending on which segment holds the
speech.
The fix is now well-posed in a way the withdrawn attempt was not: emit the
leading region only when it carries signal. That also avoids the 1524-bank
blast radius that sank the earlier version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The load-bearing error of the whole voice-bank thread, and it is mine. It
stood for three iterations across two write-ups that each called the result
proven.
I treated a subtitle cue as a timestamp that must fall INSIDE the voice clip,
and concluded a 0.07 s clip could not host a cue at 4.70 s. A cue is when the
line STARTS. The voice plays from the cue, so the clip only has to fit the
window between the cue and the end of the movie. Under that reading every
bank fits at plain 48 kHz:
bank samples @48kHz cue window
D_450 158967 3.31 4.00 5.30
D_451 76084 1.59 3.70 5.60
D_452 119562 2.49 0.00 8.34
D_453 108608 2.26 4.70 4.60
D_454 167828 3.50 0.00 9.50
2-3.5 s is also the right length for the lines. Nothing is missing, and the
17091-20563 Hz window from the previous commit is void with it -- its lower
bound came from the same misreading.
What survives, because it was measured rather than interpreted: the leading
region is XMA1 mono, the decode runs to the final frame, and cue values are
seconds.
Separately settled, and it is what exposed the error: each shared bank holds
ONE generic line. The 3-5 movies bound to a bank have IDENTICAL subtitle
text, 5 banks out of 5 -- "Rhino 3 has landed. Commencing resupply.",
"Resupply complete. You are cleared for take-off!", and so on.
That also explains the historical in-game rejection of hokyu_DS_s13A ->
VOICE_D_452 that started this whole thread. The line is generic, identical
for s02A/s07A/s08A/s13A. Someone expecting a stage-13-specific line would
hear the generic one and call it wrong -- while the binding is exactly right.
The disc said so; the subtitle text now says so independently.
Still open: whether the leading mono region is additional audio or an
alternate take, since the totals above add it to the RIFF sub-waves.
Artifact: examples/shared_bank_takes.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Two things settled, one of them a correction of my own claim from the last
iteration.
The cue unit is verified rather than assumed. parse_timing computes mm*60+ss,
but only if the token really is mm:ss.cc, so I checked against an independent
oracle: the movies are on the disc and a cue must land inside its own movie.
66 English movies with subtitle tracks, 0 cues land after the movie ends.
Centiseconds would have overflowed essentially all 66. The seconds reading
stands and the verdicts built on it survive.
"The sample rate does not converge" does not. I reported implied rates of
39742 / 20563 / 23108 Hz as irreconcilable. They are not estimates of the
same quantity -- each is a ONE-SIDED BOUND. The audio must be at least as
long as the last cue, so samples/cue is an UPPER bound; it cannot outlast its
movie, so samples/movie is a LOWER bound. Intersecting:
bank samples cue movie lower Hz upper Hz
VOICE_D_450 158967 4.00 9.30 17091 39742
VOICE_D_451 76084 3.70 9.30 8180 20563
VOICE_D_453 108608 4.70 9.30 11677 23108
=> 17091-20563 Hz, non-empty. A single rate IS consistent.
I had been comparing them as competing point estimates, which is why they
looked contradictory.
What is still open, and stated as such: that window contains no standard XMA
rate. The lower bound assumes a whole bank plays inside one movie, and each of
these banks is bound to 3-5 movie slots -- so if a bank holds several takes
the lower bound is void, leaving rate <= 20563, which 22050 nearly meets.
Next step recorded: establish whether a shared bank is one line or several.
Artifact: examples/cue_unit_check.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
I had been discarding FFmpeg's stderr with Stdio::null(). It says exactly
what happens: an unimplemented "Reserved bit", then a negative bit-skip --
and the failing frame index is always the last one.
bank packets frames fr/packet fails at
VOICE_D_450 8 45.7 5.71 44
VOICE_D_452 7 29.4 4.21 28
VOICE_D_453 22 198.9 9.04 198
VOICE_D_454 29 287.5 9.92 287
So the previous entry's "the decodes are visibly partial" is wrong, and it
was mine. I read "samples per input byte ranges 2.10-4.96" as truncation; it
is ordinary XMA1 variable bitrate. Only the final frame of each stream is
lost. The decode is essentially complete.
The sample rate still does not converge. I tried the obvious repair --
counting the whole bank, leading region plus RIFF sub-waves, since the two
split the audio very differently per bank. Two banks then agreed at a tidy
~2.1x ratio pointing near 22 kHz, and the third refuted it: implied rates are
39742, 20563 and 23108 Hz.
So the container is identified, the decode is essentially complete, and the
duration still does not reconcile -- which moves suspicion to the other side
of the comparison. movie_subtitle::track_voice_cues returns (u32, f32) and I
have been reading that f32 as SECONDS on the strength of the format notes
describing mm:ss.cc cue text. If it is centiseconds, a frame index or a
per-page offset, every "audio missing" verdict inherits the error. Recorded
as the next thing to check, and to be checked BEFORE any more audio work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Retried the format probe with the fmt chunk built to synth_xma1_fmt's exact
byte layout, and with the bank's own RIFF sub-wave decoded through the same
pipe as a CONTROL so a broken harness cannot masquerade as a result.
The channel count is the whole story:
bank lead B channels=2 channels=1
VOICE_D_450 16384 1792 46756
VOICE_D_451 2048 1792 896 (all-zero region: control)
VOICE_D_452 14336 1792 30154
VOICE_D_453 45056 1792 203648
VOICE_D_454 59392 1792 294440
channels=2 yields EXACTLY 1792 bytes for every bank regardless of size -- one
frame, then it stops. That constant is the tell. At channels=1 the same data
yields up to 113x more, and the control sub-wave decodes to 13568, so the
pipe works.
Why the previous probe got 0 bytes everywhere is now named: I read
synth_xma1_fmt(2, 2, 48000)'s second argument as a STREAM COUNT when it is a
CHANNEL MASK, and built the WAVEFORMATEX around that misreading.
Also recorded as a refutation, because it was tempting: solving for the
sample rate as decoded-samples / last-subtitle-cue does NOT converge. D_453
implies 21665 Hz -- close enough to 22050 that I nearly wrote it down -- but
D_450 implies 5844 Hz. No single rate explains both, and the decodes are
visibly partial (samples per input byte ranges 2.10-4.96 where a clean decode
would be near-constant).
So the container is identified and the duration is not. Next step recorded:
find why FFmpeg stops early, likely the hardcoded packet/subframe fields.
Artifact: examples/slb_fmt_probe.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The corpus said 0.14 s is "far too short for the spoken line". That is a
judgement about audio, and audio judgements cannot be made in this container.
The subtitle tracks settle it without listening: each carries cue START
times, and a subtitle that appears at t seconds cannot sit inside a clip
shorter than t.
FFmpeg-measured (not estimated from a compression ratio -- the first version
of this example used an 8:1 guess, which is not good enough to hang a
conclusion on):
hokyu_LS_s02A D_450 cue 4.00 s audio 1.41 s MISSING
hokyu_LS_s09A D_451 cue 3.70 s audio 1.81 s MISSING
hokyu_LS_s02H D_453 cue 4.70 s audio 0.07 s MISSING
hokyu_DS_s13A D_452 cue 0.00 s audio 1.21 s no signal
hokyu_DS_s07H D_454 cue 0.00 s audio 0.21 s no signal
Three of five are decisive; the other two have their only cue at 0.0 s and
say nothing either way. So something is genuinely missing from these banks --
established independently of the leading-region work, and measured rather
than felt.
The fmt-variation probe I recorded as the next step is INCONCLUSIVE and is
written up as such: 36 combinations over VOICE_D_453's 22-packet leading
region all produced 0 PCM bytes, including ones that should be equivalent to
the crate's own synth_xma1_fmt, which does parse. So the probe tested my
hand-built fmt chunk, not the hypothesis, and it is NOT evidence that the
region is non-XMA. The retry should use the crate's helper.
Artifact: examples/voice_len_vs_subs.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The structure is exact. In all five resupply banks the first RIFF sits at
HEADERLESS_DATA_OFFSET + n*2048, where 1392 is a constant this crate already
had and 2048 is the XMA1 packet size: n = 8, 1, 7, 22, 29. No free parameter
to tune, and the raw bytes agree -- high entropy from offset 0, then a zero
run immediately before the RIFF. VOICE_D_451 is the control, its single
packet being all zeros.
So I made the obvious fix, emitting that region as a sub-wave, and then
withdrew it on two measurements:
* It does not recover audio. Coverage went 5.4% -> 89.9% for VOICE_D_453, but
the emitted stream decodes through FFmpeg to 1792 PCM bytes -- silence --
while the RIFF sub-waves from the same banks decode to 150-270 KB. Byte
coverage was the wrong success metric and it looked like progress.
* It is not narrow. The rule matches 1524 of the 8021 RIFF-bearing entries in
sound.pak, including RT* movie banks that decode correctly today. Landing
it would have risked a wide regression in order to not-fix five banks.
to_xma_riffs is back to its previous behaviour, verified by re-measuring:
coverage is 5.4% / 9.7% again. The refuted attempt is recorded in the code
beside the branch it would have changed, so the next person does not
re-derive the arithmetic and re-make the change.
XMA1_PACKET is kept as a named constant because the blast-radius scan uses
it. Artifacts: examples/voice_bank_shape.rs (structure), voice_bank_dump.rs
(sub-waves for decoding), slb_hybrid_scan.rs (the 1524 count).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The record table gives a DIRECT binding hokyu_DS_s13A -> VOICE_D_452, where
the corpus records the movie as unbound and movie_manifest_disc.rs asserts
None, citing an in-game verdict that this exact value was "the wrong
recording". That is the only place on the disc where a runtime observation
disagrees with the record table, so it was worth settling.
First, shape: these banks are SHARED. Five slots bind VOICE_D_452, five bind
451, four 450, four 453, three 454 -- 21 hokyu slots over five banks, and the
movies repeat too. Generic resupply cutscenes, not per-stage recordings.
The recorded explanation for 453 decoding to 0.14 s and 454 to 0.43 s was
that the banks are "likely multi-subwave / not cleanly sliced". Refuted: the
count of RIFF magics EQUALS the number of sub-waves recovered in all five
banks, and the last data chunk ends exactly at EOF in four of them. Nothing
between or after sub-waves is being missed.
The real defect: slb::to_xma_riffs finds audio by searching for the RIFF
magic, and a large region PRECEDES it. 87% of VOICE_D_453 and 85% of
VOICE_D_454 sit in front of the first RIFF -- 21-27% zero over 256 distinct
byte values, i.e. content, not padding. VOICE_D_451 is the control: its
leading region is 100% zero, 1 distinct value, real padding.
So the in-game verdict listened to a decode that had discarded most of the
bank, for exactly this bank class. It is evidence about the decoder, not
about the mapping. Note also that what was rejected was a value INFERRED
from a shared demo id; the record table supplies the same value as a stored
field, and only the inference was ever tested.
This does NOT establish the binding is right -- it removes the only recorded
evidence against it. What the leading region actually holds is undecoded, and
confirming the binding needs a human listening.
Artifact: examples/voice_bank_shape.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Last iteration I said launch_mission died because skip_intro only tests for
the title on a static frame, gated at rmse <= 1500, and that run logged 1503
and 1549 just above the cut. I also said the fix was NOT to nudge 1500 but
to measure both signals through a boot first. Measured, and the diagnosis
does not survive.
boot_trace.sh logs the two signals skip_intro decides on -- frame-to-frame
RMSE and the is_title.py green-glyph count -- through a clean boot with no
presses at all. One run, 29 samples over 484 s:
8 samples had rmse <= 1500, so the gate OPENED eight times
0 samples had glyph > 0, so the title was never seen
At t=145 s the RMSE was 1205, comfortably inside the threshold, and the
glyph test was called and answered zero. A frame can be perfectly static
without being the title -- the intro movie has long quiet stretches, three
reading RMSE exactly 0. So 1503/1549 were almost certainly movie frames too,
and raising the constant would have admitted two more of them.
What is left is narrower and honest: the interactive title never appeared,
rather than appearing and being missed by a threshold.
The limitation is recorded rather than buried: the tracer intended 1 s
sampling and achieved 16.9 s, because each iteration forks two screenshots,
ImageMagick compare and a fresh Python. So this does NOT prove the title
never appeared -- only that it was absent from 29 samples. A window shorter
than ~17 s falls between them. The recorded next step is to make the tracer
sample at the rate it claims before concluding anything stronger.
Artifact: docs/re/captures/boot-signal-trace.tsv.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Third independent line for yesterday's built-in 100 rename, from the callee
this time. sub_8226E3B8 was labelled "push", which is what made built-in 100
look like push_trigger. It reads the element count, returns immediately when
the container is EMPTY, and otherwise walks the node list splicing nodes out
until it is empty. A push links one node; this unlinks all of them. It is
clear(). The append is sub_8226E160, reached from built-ins 19 and 25.
So the rename now rests on the handler, the usage (all 12 Stage 02 sites sit
in the phase terminator next to timer_stop / clear_flag(-1) /
MARK_LAST_PHASE), and the callee.
The dynamic half did NOT run, and the write-up says so. phase_watch.py now
samples [phase+272+20] (triggers queued) and [phase+216+8] (coroutines
alive) so a phase terminator's effect on the VM is visible in one line —
written here, never yet exercised against a live guest.
Boot-nav could not reach the title in 381 s. Diagnosed rather than retried:
skip_intro.sh only runs the title test on a static frame, gated at
rmse <= 1500, and this run measured 1503 at 104 s and 1549 at 139 s — just
above the cut — so is_title.py was never called and the one allowed press
was never spent. Recorded in BACKLOG with the explicit instruction NOT to
raise the constant: the first step is to log rmse and the glyph count
through a whole boot and look at the two distributions, because tuning a
threshold to make one run pass is fitting to a single sample.
Also reaped a stale lock: a gdb orphaned 2h14m earlier was holding
/tmp/xenia-canary.lock with an already-defunct emulator child.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
With the record table decoded there is finally a ground truth to check the
old string-pool reader against. It infers `key -> value` from pool adjacency,
which is a consequence of how records are written, not a rule of the format.
Verified by hand against the disc, with an independent parser:
* `FCSRange` = 500000.0 — the module docs' own canonical example of a field
"left at its default" that "omits the value string".
* `ShieldRatio` = 1.0, where `tests/pak_idxd_disc.rs` asserted None with the
comment "a defaulted/omitted field must be None". That test encoded the
false belief; it now keeps the None as a deliberate characterisation of the
legacy reader, with the true value asserted beside it.
* `get_raw("Model")` on GP_HANGAR_ARSENAL returns the first record's model for
every record — silent corruption, not an absent value. New test pins four
records that disagree with it.
The cause is the flat API having no way to name a record: only 548 of 6325
objects have one. `HP` on the DeltaSaber answers 1000.0, the hull, while 63
Turret_* records each carry their own 100.0 (measured — a first draft said 34,
taken from a report rather than from the disc).
Disc-wide rates are recorded as single-source and labelled as such: get_raw
52% wrong, typed getters 38% miss, but 100% correct on single-record objects.
Also records a negative result: the 504 unnamed field keys were NOT recovered.
A 572464-string dictionary and 73191 variants gave 0/42. The key deltas do
prove the preimage ends with the two decimal digits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The binary region in front of the string pool was the parser's oldest open
note ("Not yet decoded"). It is a uniform 16-byte record array sorted by
name hash, a field count, a 12-byte field array sorted by key, a pool size,
and the pool. The trailing `pool_size == file_len - pool_base` identity makes
the layout self-checking, which is what caught the first wrong version.
Verified over the WHOLE disc with zero failures: 7750/7750 IDXD objects,
190782/190782 records reproducing their stored tag_hash, 1271462/1271462
named fields reproducing their key. IXUD is the same container with
ixud_hash, UTF-16BE and every offset in chars — 1104/1104 objects,
628165/628165 fields, checked with an independent parser.
Field names are stored on disc, so no preimage search is needed: a field's
middle word points at its own name. Only 504 fields disc-wide are hash-keyed
with no name; the other 1485073 nameless fields are positional, keyed by a
literal integer (line slots, movie ids).
Two long-held beliefs are WITHDRAWN:
* The word at 0x08 is not a schema hash. It is record 0's name_hash — the
format has no type field at all, and an object's kind is known only from
the caller that loads it. It survived as "schema" because tables of one
kind share their lowest-hashed record name. Caught by a test asserting
every movie id names a real record: 1005 -> STAGE10_PHASE01 failed because
tag_hash("STAGE10_PHASE01") IS 0x067025B9, that table's supposed schema id.
* The field's middle word is not an always-0xFFFFFFFF flags word. It is
0xFFFFFFFF for 54% of fields, enough to look constant in a small sample;
the tell was that it is constant per key ACROSS records, which a per-record
flag cannot be but a per-name pointer must.
`schema_hash` keeps its name rather than churn 33 call sites, with corrected
docs. The first sweep globbed dat/** and missed hidden/DefTables.pak (1425
objects); the test now walks the whole disc root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Re-extracting the same gdb capture per thread rather than by grep qualifies the
previous entry. Of eighteen threads whose frame 3 is XObject::Wait, eight have
[rbx] equal to the XEvent vtable plus sixteen, two equal to the XSemaphore
vtable plus sixteen, and eight hold a pointer into the mmap region that is not a
vtable at all. So the waits are on two distinct kernel types, XEvent and
XSemaphore, and the earlier claim that the object is an XEvent was right for the
majority but not the whole picture.
The eight non-vtable readings are the method checking itself rather than a
failure. A polymorphic object's first word is always a vtable pointer, so those
values are simply not this: rbx could not be restored for those frames and the
unwind returned whatever the register held. A reading counts only if [rbx] lands
in the binary's vtable range and resolves to a "vtable for" symbol; anything
else is discarded rather than interpreted. Ten of eighteen resolve and the rest
are honestly unknown.
That also settles the previous entry's worry that the 0x7ffc and 0x7ffd
addresses looked like stack. They are the shared mmap region, which holds thread
stacks and large allocations alike, so the vtable check rather than the address
range is what separates an object from a stack slot.
The follow-up run that would have added /proc/<pid>/maps classification and a
wider object dump never booted -- EMULATOR GONE at 0s, skip_intro exit 4 --
most likely a stale emulator or lockfile from the preceding gdb session, whose
process tree is parented differently and escaped the usual cleanup. So the map
classification, the multi-word object dump and the frozen-state capture are all
still unrun.
Executed the wait-object read on a live run under gdb. For every thread whose
frame 3 is XObject::Wait, rbx gives a pointer whose first quadword is
0x5555562db8f0, which is the PIE base plus 0xd878f0, and the symbol table has
vtable for xe::kernel::XEvent at 0xd878e0. The stored pointer is the vtable
symbol plus sixteen, since offset-to-top and RTTI come first, so it matches
exactly. The same vtable appears on every sampled waiting thread while the this
pointers differ, meaning many threads waiting on different XEvent instances. The
whole chain needs no DWARF and no rebuild, as the static groundwork predicted.
Two caveats, both recorded rather than smoothed over.
This is a healthy-play snapshot, not the freeze. The capture landed at 195 s of
flight with screen_id reporting flight and a non-black mean, because boot under
gdb costs about 300 s and the entire experiment has to fit inside one call --
a timeout kills the process group and takes the emulator with it, which lost an
earlier attempt outright. So this describes what threads wait on during normal
play, which is the control the frozen capture never had, but it is not the
frozen case.
And the this addresses look like host stack rather than heap. Either xenia
places these objects somewhere unusual, or rbx at frame 3 is not Wait's this
after the unwind and the vtable match is coincidence. An exact plus-sixteen
match on a known symbol is hard to get by accident, but the address range is not
what was expected. The settling check is to dump a few words at rbx and see
whether they look like an XEvent -- vtable, KernelState pointer, handle and type
fields -- or like saved registers.
Pure static analysis, no run spent. XObject::Wait's prologue does mov %rdi,%rbx
at 8fbc9c, so the this pointer lives in a callee-saved register rather than a
stack slot. And the binary carries full unwind information: .eh_frame with
127231 FDEs, which survives in Release builds because C++ exceptions need it,
including an FDE covering 8fbc90 to 8fbde2 that tracks rbx explicitly.
Together those mean that from a thread frozen deep in pthread_cond_wait, moving
to the XObject::Wait frame and reading rbx yields the XObject being waited on --
gdb reconstructs callee-saved registers during the unwind from .eh_frame alone,
with no DWARF involved. Reading the first quadword at that pointer gives the
vtable, and vtable symbols are in the symtab, so the object's concrete type is
identifiable too.
This revises the previous entry, which listed route 1 as per-frame archaeology
that must be redone whenever the binary changes, and route 2, a RelWithDebInfo
rebuild, as what would make the question easy. Route 1 is neither expensive nor
fragile: two gdb commands per thread, no rebuild, and the oracle stays
byte-identical to the binary every other measurement in this corpus was taken
against.
Not yet executed on a frozen run, which is the next step and is now a small one.
Two corrections to the plan the freeze file leaves for the next pass, both cheap
to establish and both worth having before a run is spent discovering them.
The export in the frozen frame is confirmed. The shim frame carries its ordinal
as a template parameter, 176 decimal, which is 0xB0, and xboxkrnl_table.inc line
190 maps that to KeWaitForSingleObject. The identification was previously read
off frame names; it is now pinned by ordinal.
The file's stated next step does not work on this binary. It says the wait
target is one info-args-equivalent away now that a debugger can be attached, but
readelf shows zero debug sections and only a symtab of 27807 entries. The
Release build is not stripped, which is why frames carry function names, but it
has no debug info at all, so gdb can name a frame and nothing inside it: no info
args, no p *this, no member access. The earlier note conflated "not stripped"
with "has debug info".
Two routes remain and their costs are now recorded. A prologue-guided stack read
can recover XObject::Wait's spilled this pointer from a known stack slot, needing
no rebuild and keeping the oracle byte-identical to the one every other
measurement in this corpus used, at the price of per-frame archaeology that must
be redone if the binary changes. Alternatively a RelWithDebInfo build via
build-canary makes this and every future freeze question straightforward, at the
price of a full compile and a binary that differs from the measured one.
Neither is attempted here. Recorded so the next pass picks with the costs
visible.
One new data point on the in-mission freeze. The refuted resume-spin lead rested
on refused resumes being normal -- thousands during gameplay, more in a healthy
run than a frozen one. Today's freeze log contains zero of them across 1147
lines, and the mission still froze at about 267 s with a black screen, against
2447 in an older log. So the warning is not even necessary for a freeze, let
alone sufficient, which closes the lead from the other side.
Also refuted today, before this file was found: the burst of BaseHeap::Release
failures at the end of the short log looks like a freeze signature and is not.
In the longer log the same failures span lines 1044 to 5210 and the log
continues for 2700 lines afterwards; they begin at mission load in both runs and
are routine.
The uncomfortable part is that both were already settled in
mission-freeze-resume-spin.md, as was the 0xbdb59668 address that a previous
iteration rediscovered independently. That is twice in one session that existing
work was redone.
The cause is mechanical rather than forgetfulness. docs/re/INDEX.md listed 20 of
43 notes and none of the recent ones, so searching the index for prior work on
the freeze returned nothing -- the corpus was searched, but the search was
blind. INDEX.md now carries a generated table of every note under docs/re/, 59
entries with title and status, and states outright that it should be searched
before starting an investigation. Regenerating it is a few lines of Python and
should be redone whenever notes are added.
A two-segment OB watch made the mechanism visible. Segment 1 ran clean, with the
witness reporting zero stalled samples of fifty and OB steady at 4 for 250
seconds. Segment 2, attaching to the same still-alive emulator, got "NOT IN A
MISSION (no unit definitions)".
The emulator was alive; the mission was gone. The pilot's hull reads 1500 in
every line, so the player never died. Its log stops at t=267 s with consecutive
byte-identical lines, the freeze signature. And the screen is now entirely
black, mean (0,0,0). This is the pre-existing mission-end black-screen freeze
already recorded in the corpus, caught in the act. Segment 1's clean witness is
not a contradiction, since it sampled to about 250 s and the freeze came at 267.
That corrects an earlier claim. mission-arrival-watch.md records the blocker as
removed because the emulator survives between calls, which is true, but it was
stated as though observation could be extended indefinitely within a turn. The
emulator surviving is not the mission surviving. The binding limit is the game's
own black-screen at roughly four and a half minutes and no amount of chaining
crosses it. The earlier chained run that reached 435 s of cumulative
verified-live observation did so across two segments of one mission, which fits:
its second segment froze at t=135 s.
The consequence is worth stating plainly. Any experiment needing more than about
four and a half minutes of one continuous mission is not doable on this build,
chained or not, and that includes clearing sixteen marked fighters at two kills
per five minutes. The freeze is therefore now the highest-value target in its own
right, since fixing it would unblock the arrival question, the phase-advance
question and the OB series together. It is also a long-standing known defect
rather than anything this work introduced.
ob_watch.py verifies the address against a confidence-gated HUD reading before
reporting anything, and two consecutive fresh launches settle the question:
0xbdb59668 held 3165285888 against a HUD of 4 on one, and exactly 4 on the next.
The address is not stable across launches, the old note that it recurs in about
five runs of seven was right, and the gate did its job by refusing to report a
series from an address that did not describe that run. The watcher now hunts the
address on the current run when confirmation fails, using the same intersection
method, so it no longer depends on a lucky launch.
The HUD reader is also gated now. ob_read returns a best and second score per
digit and those were printed but never checked, which is how one misread
poisoned an intersection and produced a wrong refutation of big-endian u32. A
reading is accepted only if every digit scores at least 0.80 with a margin of at
least 0.05, the rule ob_read's own docstring states.
The measurement itself is a negative. With a clean witness, zero stalled samples
of fifty, OB held at 4 for 250 seconds while the pilot targeted e010 for 1964
ticks and fired on 1635 of them. Constant fire at the marked attackers and not
one decrement, so it destroyed none, which matches the roughly two marked kills
per five minutes measured earlier. The fire rate itself rose from 4.6 % of ticks
in an earlier diagnosis to 83 % here without producing more kills.
Recorded as unreproduced rather than explained away: the run that found the
address saw the counter rise 4, 8, 12 over five minutes, and that reading was
confirmed against the HUD. This run was flat over a comparable window. Both
observations are sound and they disagree, so the rise is not a stable property
of the mission's first five minutes and presumably depends on progress this run
never reached.
Ported onto the shared harness, the HUD changed from 4 to 8 and the intersection
collapsed in a single step: one u32be survivor at 0xbdb59668, with the u16be and
u8 hits at 0xbdb5966a and 0xbdb5966b being the low half and low byte of that same
word.
Verified live rather than asserted. Reading screenshot and memory together three
times, the one legible HUD frame showed 012 against mem@0xbdb59668 = 12; the
other two frames were unreadable rather than mismatched. This independently
rediscovers the address the earliest sessions found by digit-transition hunting,
by a completely different method.
It also corrects an earlier conclusion. A previous run intersected HUD readings
of 4 and then 11, got zero survivors in every encoding, and that was written up
as eliminating big-endian u32 for the whole region. This run shows u32be holds
the counter, so the refutation was wrong. The likely cause is the input:
ob_digits.png has templates for 0 1 2 4 8 only, so values containing other
digits are misread rather than rejected, and "11" was probably one of those. A
single bad reading poisons an intersection permanently, because it removes the
true address and nothing later can restore it. The lesson is that an
intersection method needs individually verifiable inputs -- the reader's
confidence scores were printed but never gated on.
One observation reopens the arrival question in a useful way: the counter
increases, 4 then 8 then 12 across about five minutes, measured in memory so not
a digit misread. A count of remaining marked targets that rises means targets
are being added during the mission. That does not contradict the deployment
finding, since the roster is fixed at load, but it does mean the game marks new
objective targets as the mission proceeds. Watching this one address across a
whole mission is now the obvious next experiment and costs almost nothing.
Four probes were written from a blank file and each re-learned the same lessons
by losing a run: that a flat run cannot be told from a frozen guest without a
stall witness, that results held to the end of a run are destroyed by a turn
timeout, that a roster count which is not the stage's member count means a
different stage loaded and must be discarded, and that a run's witness state has
to be read before its numbers. Writing each lesson down did not stop the next
probe repeating it, because each probe started from nothing.
probeharness.py makes them structural. Probe(baseline=N) discovers the roster,
rescans up to five times and refuses to start if the count never reaches the
baseline. The witness is calibrated on construction, sampled by tick() and
reported by status() and summary(), so a probe cannot forget it, and when no
witness is found it reports UNVALIDATED rather than zero stalls. emit() flushes
on every line. craft(), strengths(), alive() and heap() supply the
roster-to-craft link, per-record liveness and the raw heap, so a new probe
writes only its own logic.
Verified rather than asserted: deploy_probe.py reimplements the per-record
deployment watch on top of it in about forty lines against wave7_probe's
hundred and fifty, and its first live run was clean -- 116 roster records, 32
witnesses at 10/s, zero stalled samples, seven losses tracked, and the TSV
written incrementally. Nothing about the result is new, which is the point: the
harness reproduces a known-good measurement.
The existing probes are deliberately not ported. They work, and rewriting them
would risk changing results other documents cite. New probes should use the
harness; old ones should be ported when they next need a change.
ob_by_hud.py now scans seven readings of the same bytes and keeps a separate
candidate set for each, as byte offsets: u32 big and little endian, u16 both
endiannesses at both alignments, and u8. The big-endian u32 reading had been
refuted, so widening rather than assuming is the point.
u32le is much the tightest at 154 candidates against u32be's 4452. That is a
hint about the encoding rather than a result, since a rarer bit pattern narrows
faster regardless of meaning.
The run is inconclusive. The HUD read 4 at every sample, so there was no second
value to collapse the sets against, and from t=136 the candidate counts are
byte-identical across five samples in all seven encodings, which is what a
frozen guest looks like -- nothing in 32 MB changed at all. The probe had no
stall witness, so the run cannot prove it either way. One is added now.
Worth stating plainly: this is the fourth probe written without a witness and
the third whose flat output could not be distinguished from a freeze. Each time
the fix gets applied to that one script. The durable fix is the shared probe
harness already noted in this file, and the lesson recurring four times is
itself the argument for building it.
What the hunt needs is unchanged: two HUD readings at different values in
non-stalled samples. The counter moves on kills, which lands back on the combat
limit, though the earlier 4 to 11 observation shows it does move.
The correlation route is gated on marked-fighter kills, which the pilot manages
at about two per five minutes. ob_read.py already reads the counter off the
screen, so ob_by_hud.py matches the displayed value against memory directly and
needs no kills at all: screenshot, read the digits, keep heap words equal to that
value, intersect across readings.
Four readings at value 4 narrowed 6156 candidates to 4312, the expected slow
drift. Then the HUD read 11 and the intersection collapsed to zero. A word
holding this counter must equal 4 at the first four samples and 11 at the last,
and none does, so within the entity heap read as big-endian u32 the counter does
not exist. It may be u16, u8, little-endian, or outside that region. Both
previous hunts assumed big-endian u32 there, so this eliminates the assumption
rather than merely failing to find anything.
The displayed value also went up, from 4 to 11 over about 340 seconds. A pure
countdown of remaining marked targets should not rise, and the deployment work
says phase 1 gains no new participants. Three readings are possible and none is
tested: the cell being read is not REMAINING OB, the digits are misread, or the
counter genuinely counts something that can increase. The two clean readings
scored 0.95 to 0.98 against their templates, but 4 and 11 use only digits that
are in the strip, which is exactly the selection effect that would hide a wrong
reading -- the template set covers 0 1 2 4 8 only, and most samples came back
unreadable.
Next is widening the scan to u16 and u8 and to little-endian, and beyond the
entity heap, which is a change to one function and costs no combat. Extending
ob_digits.png with the missing digits would also raise the sample yield, since
only two of eleven readings in a 480 s run were usable.
With the value filter in place, an attach watching turret losses -- frequent,
unlike marked-fighter kills -- narrowed from 374 candidates to 2 to 0 across
three events. That is exactly how a correlation search should behave, and it
ends in a refutation rather than fizzling out: no plain u32 in the entity heap
decrements consistently with turret kills.
Witness checked first, per the rule adopted last iteration: 12 of 25 samples
stalled, but all four kill events fall in the early non-stalled stretch between
t=45 and t=177, so the events themselves are sound.
The negative fits the objective text rather than contradicting it. Stage 02
phase 1 asks to shoot down all invading enemy fighters, and turrets are not
fighters, so a counter of remaining marked targets should not move when a turret
dies. It also rules out the cheaper reading that REMAINING OB is a general kill
or enemy-remaining tally: it ignored ten turret deaths.
The method is proven and the search region is right, since 0xbdb59668 where the
counter was originally seen lies inside the scanned range. What is missing is
unchanged -- two or three e010 kill events in non-stalled samples -- against the
same two obstacles, the freeze rate and a pilot managing about two
marked-fighter kills per five minutes. One clean 220 s run this iteration
produced zero.
The incremental-save fix is verified. A fresh mission caught one e010 event at
t=241 s and wrote 1187 candidates to disk immediately; the turn timeout then
fired exactly as before, but this time the data survived. The session also
clears the candidate file at launch, since candidate offsets are only meaningful
within one emulator instance and resuming across launches would intersect
unrelated addresses.
The correlation itself was wrong though. It matched on the delta alone, so any
two float bit patterns whose integer representations differ by the loss count
qualified, and in a heap full of positions and velocities that is thousands of
words. The 1187 survivors were things like 1044450858, about 0.1f, and
3212461993, a negative float. Candidates must now also look like a counter --
a small non-negative integer in both samples -- which removes the noise by
construction instead of hoping the intersection washes it out.
The follow-up attach logged zero events across 520 s, which reads like the
combat-effectiveness limit again. It was not: 25 of its 26 samples were flagged
GUEST STALLED, so the guest was frozen for essentially the whole window. The
witness added last iteration did its job, and the lesson is about reading it --
the run summary quoted "0 events" first and the stall count only surfaced on a
deliberate check. A run's witness result should be the first thing looked at,
before any interpretation of what the run showed.
Still unfinished, with no address identified. What is needed is unchanged, two
or three e010 kill events in non-stalled samples, and the two obstacles are now
clearly separate: the freeze rate, and a pilot that manages about two
marked-fighter kills per five minutes.
The previous commit landed the doc and the probes but its BACKLOG edit asserted
on text that had since moved, so the index did not mention the hunt at all.
That is the third time an assert-then-replace patch has silently failed this
way. The pattern is the problem: the assertion aborts the edit but the commit
that follows still succeeds, so a missing index entry looks like a clean run.
Locating the anchor first and verifying the insert afterwards, as done here, is
the habit to keep.
TextIndex in sylpheed-formats::localization already indexes per-phase objective
text under S<NN>_P<n>_Objective_<i>, with hints and lose conditions alongside.
Querying it settles what the wave work spent many iterations circling, and a
disc test now pins it.
Stage 02 phase 1 is "Shoot down all invading enemy fighters while watching out
for attacks on the ACROPOLIS", losing if the ACROPOLIS is sunk. Phase 2 is
"Protect the Admiral's ship the CALIBAN until it has entered the safe zone",
losing if the CALIBAN is sunk. Phase 3 is "Destroy the interstellar cruise
missiles headed for Schlos Base", matching phase 3's roster of exactly nine
ISCMissile and SUBOBJ_013.
That explains the phase advances and shows they are not all the same kind. Phase
1 is a kill-all-marked-fighters gate -- fighters, not the turrets every run
killed almost exclusively. Phase 2 is positional and ends when the CALIBAN
reaches a safe zone, so "what advances a phase" never had a single answer.
It also closes a loop to the first session. The hints say red mission markers
[OB] indicate your targets, so REMAINING OB -- the HUD counter hunted in the
earliest iterations and located at 0xbdb59668 -- is the count of remaining
marked objective targets, which is phase-1 progress itself. The old reframing
that the counter is not a roster was right, and this names what it is. It is
also the correct signal to watch for an advance: not deployed, which only
changes once the next phase deploys, but REMAINING OB reaching zero, which is
the cause.
Method lesson recorded because the reflex will recur: localization.rs has had
objectives, hints and lose_conditions for some time, and several iterations were
spent reconstructing the same information by hand from SUBObjective strings and
the guide script. The reconstruction was not wasted -- it produced the IXUD
UTF-16BE and language\ prefix findings independently -- but the corpus should
have been searched before the disc was, and grep -rl TextIndex crates/ would
have saved the detour.
Minor discrepancy noted, not investigated: ixud.rs documents the string pool as
UTF-16BE and localization.rs as UTF-16LE.
pilot.py gained SYLPH_PREFER, a unit-name substring whose matches get their
target score multiplied by 0.05 while everything else is multiplied by 4.0. With
SYLPH_PREFER=e010 a clean 320 s run, zero stalls by the witness, killed eight
turrets and two Attacker_S.
The preference is real -- e010 kills went from roughly one across all previous
runs to two in a single run -- but it is weak. Turrets still outnumber attackers
four to one in the kill log, because target commitment and simple proximity keep
pulling the nose back to them, and phase 1 fields 108 turret craft against 16
attackers. Deployed stayed at 41 throughout, so no phase advance.
That quantifies the blocker. Clearing the marked attackers means destroying 16
craft, and at two per 320 s that is about 2560 seconds, roughly 43 minutes of
continuous verified-live flight across many chained attaches, against a freeze
rate of about two runs in five.
This is no longer a reverse-engineering problem. Everything needed to observe
the phase advance is built and validated -- the roster-to-craft link, the
liveness read, the stall witness, chained attaches and the discard rule. What is
missing is a pilot good enough to complete the mission objective, which is
game-playing work with an uncertain payoff.
The choice is recorded rather than made, because it is about how much effort one
confirmation is worth rather than a technical unknown: invest in the pilot,
accept the static answer where only the trigger is inferred rather than
observed, or attempt one very long chained run betting against the freeze rate.
The localised string tables are decoded. language\*_local_string.tbl uses a
third archive prefix, language\, and is not IDXD: the magic is IXUD and the
strings are UTF-16 big-endian, which is why earlier ASCII-oriented dumps of
these files produced garbage.
That makes Stage 02's nine sub-objectives readable in words -- rescue the ally
destroyer, destroy all enemy fighters, sink all enemy warships, rescue the
Amalthea, keep the Caliban undamaged, sink the destroyer targeting the
Acropolis, destroy all enemy cruise missiles. Ninety-one objectives are defined
across the game.
The guide script then confirms the phase structure twice over. It reads as three
acts -- marked attackers and protecting the Acropolis, then a warship whose
engine, weapons and shield projector must be destroyed, then cruise missiles
taken down one by one -- and each act matches the per-phase roster composition
found independently. Phase 3 fields exactly nine UN_e201_ADAN_ISCMissile against
SUBOBJ_013's "You destroyed all enemy cruise missiles", an exact match between
two files decoded on different days by different routes.
And it gives a mundane explanation for every null result in the arrival work.
The phase-1 objective is stated outright in the script: the attackers with the
orange markers are the targets, meaning UN_e010_ADAN_Attacker_S, of which phase
1 fields four squadrons. Every loss line in every run log reads
UN_e007_ADAN_Turret, with e010 appearing twice in total, because SYLPH_HUNT was
built to make turrets targets and SYLPH_KEEPOUT was tuned for them. The 42 kills
were overwhelmingly the wrong 42.
Kept at 🟡 because the objective text is certain but the inference that this
particular objective gates the phase advance comes from the script's structure
rather than from measurement. The test is a pilot that prioritises e010 over
e007, which is a target-preference knob rather than new decoding, with the
existing probe watching deployed jump from 41 toward the phase-2 roster.
StageMessageSet_S02.tbl is resolved. stage-mission-tables.md had recorded it as
absent from the pak; it is present, and the archive prefix is message\ rather
than stage\ like its siblings. Sweeping prefix candidates over the full 16630
entry TOC found it immediately. It holds a voice mapping, a unit message set
pointer, and a list of 31 message tables including ScriptMessage_S02_msg.tbl and
the per-character PresetMessage sets.
message\UnitMessageSet_S02.tbl then gives an independent confirmation of the
phase structure. Its 26 records have exactly four named fields: CrewCount and
PresetMessage_Phase1, Phase2 and Phase3. Every message set carries a different
preset table per phase, so phases now appear in three unrelated table families --
the stage record, the route names, and the message sets. They are not a quirk of
one file.
The promising lead did not survive. ScriptMessage_S02_msg.tbl has 149 records
whose third positional field takes values like Killed and None, which looked
like a trigger. Enumerated across S01 and S02 it is a small closed vocabulary of
None, Emergency, Killed and Noise -- delivery categories for radio chatter,
meaning how a line is played rather than mission control flow -- and the fourth
field is 1 in every record, so it is not an argument either. Rejected.
That leaves no table anywhere carrying a phase-advance condition. Everything
found is a consequence of the phase and never its cause, which points at the
advance logic living in code. The only place left is the executable, and
default.xex is encrypted on disc, so the decrypted image exists only in guest
RAM and reaching it needs a run plus function-level work. Recorded as the honest
end of the static search rather than continuing to guess at table names.
Cross-referencing Route_S02 with UnitGroup_S02 gives per-phase membership: phase
1 has 37 squadrons and 42 members, phase 2 has 34 and 36, phase 3 has 44 and 49,
with squadrons appearing in more than one phase so the totals overlap. The
measured deployed count, stable across many runs and from the first observable
instant, is 41 of 116 records.
41 against 42. The deployed set is the phase-1 roster, to within one record.
That makes every previously puzzling observation coherent. All 116 records exist
at mission load, confirmed on two stages, but only the current phase's squadrons
hold craft. Deployed is 41 before the ready room ends and never moves because
phase 1 deploys as a unit at load rather than on a schedule. No arrival was ever
observed in up to 435 s of verified-live flight with 42 kills because phase 1
never completed in any run, and phases 2 and 3 hold the other 85 members. And
the route times are within-phase fly-in timings rather than release times, which
fits the frames reading where Stage 01 phase 2's 1500, 1800 and 2100 are 50, 60
and 70 seconds at 30 Hz.
So enemies come into play per phase, deployed together at phase start, with each
squadron's route giving its entrance path and the moment it flies in.
The off-by-one is not explained and is recorded rather than rounded away. The
player's record may be counted differently, one squadron may hold craft in a way
the liveness read misses, or one phase-1 member may genuinely not deploy. A
one-record gap is exactly the size of the errors that have misled this corpus
before.
What advances a phase is now the single remaining question. It is directly
observable, since deployed would jump toward the phase-2 roster. It did not
happen in 435 s of verified-live flight with 42 craft destroyed, so it is
neither a short timer nor a small kill count. SUBObjectiveSettings names nine
objectives but carries only rewards and HUD strings, no trigger. Not yet
searched: StageMessageSet_S02.tbl, which has never resolved in the pak, and the
executable, whose decrypted image exists only in guest RAM.
The run meant to catch a deployment step at take-off removed the hypothesis
instead. One continuous series of 64 samples over about 380 s, started when the
roster first appeared and running through the ready room, the transition to
flight at +47 s, and several minutes of flight, shows deployed at 41 and craft
at 292 with not one change in either number.
So the previous iteration's suggestion that deployment is a single step at
take-off is withdrawn. It rested on ready room 39/276 against flight 41/300,
which came from different runs and was flagged 🟡 for that reason. Measured
within one run the values are identical before and after take-off, so that gap
was cross-run variance.
What this does establish is stronger. The roster is already deployed when the
roster first exists: 41 of 116 records hold craft before the ready room ends and
nothing changes afterwards. Together with the roster identity confirmed on two
stages and the earlier piloted runs, Stage 02 phase 1 has no observable
in-mission arrival at all -- not in the ready room, not at take-off, and not in
up to 435 s of verified-live flight during which the player destroyed 42 craft.
The route times still mean something, since 0/90/120/170/210/240 and Stage 01's
1500/1800/2100 are structured rather than noise. The frames reading remains the
best fit, and under it they are most likely fly-in animation timings applied at
load rather than release times.
Two things stay open and are recorded as such. Phases 2 and 3 have never been
tested: every run has stayed in phase 1 and phase advance was never located, so
a phase transition is now the most likely place an arrival could exist. And this
particular run had no pilot, so it adds nothing on event-gating; the piloted
435 s run already covers that ground.
early_probe defers everything expensive: no witness calibration, no per-record
labelling, and the heap scan uses bytes.find. Setup now completes in 0.5 to 0.8
seconds instead of about 25, so the first sample lands essentially at flight
detection.
It is still flat. Deployed reads 41 at flight+0.8 s and every sample after, with
the only change in 252 s being 41 to 40 when one squadron was wiped out. No
climb at any point. Under the frames reading that is expected, since the whole
phase-1 schedule would be finished within eight seconds of mission start.
The attempt to get ahead of flight detection did not work, and the reason is
worth recording. A second run started the probe before the launch and waited for
the roster to appear. It appeared with 116 records, but the numbers were
deployed 39 and craft 276, flat for the whole window. That is the ready room:
the roster is built before take-off, so waiting for it catches the pre-flight
scene, and the probe's window expired around the time flight actually began.
Waiting for the roster is not the same as catching mission start, and the test
as designed does not do what it claims.
The two runs together do suggest something, held at 🟡 because they are
different runs. The ready room shows 39 deployed and 276 craft while flight
shows 41 and 300, so two records and twenty-four craft appear between them. That
points at deployment being a single step at take-off rather than a schedule
unfolding during the mission.
Next is one run of about 400 s with the probe waiting for the roster, long
enough to span ready room, take-off and flight in a single continuous series,
which would show the 39 to 41 step directly or refute it.
The rescan-until-baseline retry settled the anomaly: five rescans over 50 s
stuck at 42, so not a load race. Inspecting the live mission rather than
guessing, the records include UN_S01_Asteroid_cmesh_* and UnitGroup_S01.tbl is
resident while UnitGroup_S02.tbl is not. The launch sometimes loads Stage 01
instead of Stage 02, and the discard rule caught every such run, which is why
none of them contaminated a result.
That accident supplies a test previously recorded as blocked. The roster
identity needed checking against a different stage, and no second save exists.
Stage 01 provides one: static 42 members and 13 distinct units against live 42
roster records and 13 definitions, with composition matching 20/20, 6/6, 5/5 and
2/2. One record per roster member now holds on two independent stages with
completely different rosters.
Stage 01's timetable also bears on the unit question, which has been open since
the schedule was found. Its phase 2 has entries at t=1500, 1800 and 2100. As
seconds that is 25 to 35 minutes into a single phase, with three arrivals spread
across the last ten minutes of it, which is not a plausible mission. At 30 Hz
they are 50, 60 and 70 seconds, which is exactly the shape of a paced phase.
If the unit is frames, Stage 02's phase-1 entries at 90, 120, 170, 210 and 240
are 3 to 8 seconds, every one of them before the probe's first sample, which
lands about 25 s after flight is detected. That would explain every null result
in this line of work at a stroke, and it fits what the probe has always seen:
deployed already at 41 by t=0 and never changing.
Kept at 🟡 -- it is an inference from the implausibility of one reading rather
than a measurement -- but it is now the leading explanation, ahead of
event-gating. The test is to sample at flight+2 s and compare with flight+30 s,
which needs the probe's enumeration and calibration reordered after a first
cheap sample rather than any new decoding.
The previous iteration declared route entries t=210 and t=240 unreachable
because a shell call is capped at 595 s. That was wrong, and it rested on an
assumption I never checked: launch_mission.sh leaves the emulator running and it
survives between Bash calls within a turn. Verified directly -- 611 s elapsed
and still running after the launching call had returned.
attach_session.sh attaches a pilot and probe to an already-running mission, and
attaches chain, so the window is bounded by the turn rather than by one call.
On one continuous mission: 300 s of clean observation followed by an attach that
ran 135 s before freezing, giving 435 s of cumulative verified-live wall-clock.
The craft population fell from 300 to 258, so 42 were destroyed, and deployed
fell 41 to 38. Zero confirmed arrivals throughout -- the longest verified-live
observation so far. That covers the first four phase-1 route entries, t=90, 120,
170 and 210; t=240 needs 436 s and was missed by a second.
The coverage claim is qualified rather than asserted. The wall-to-game
conversion uses 55 %, from a 16.5/s frame rate against an assumed 30 Hz tick,
but the witness has measured 8, 11, 11, 21 and 24 per second across runs. At the
low end the factor is 0.27 and the same 435 s covers only about 117
game-seconds, reaching t=90 alone. So what is established is that nothing
arrived in 435 s of verified-live phase-1 flight with 42 kills; how many
scheduled arrival times that spans depends on a tick rate that is not yet
pinned, and pinning it is what would make the result sharp.
Also refuted: SYLPH_HZ=3, a lower pilot poll rate, produced the lowest
calibrated frame rate of any run at 8/s while landing the most kills at 26. The
pilot's polling is not the throttle and lowering it does not buy game time.
Computed the observation ceiling, which should have been done several iterations
ago instead of being approached one run at a time. A turn's shell call is capped
at 595 s; boot costs about 220 s, of which roughly 190 s is a title movie the
launch script waits out because tapping breaks the title; probe startup is about
25 s. That leaves roughly 350 s of observation, and at the measured 55 % of
real-time that is about 193 game-seconds.
Against the phase-1 route schedule: t=90 needs 164 s of wall-clock and t=120
needs 218 s, both already observed with no arrival. t=170 needs 309 s and is
reachable but only on a run that does not freeze. t=210 needs 382 s and t=240
needs 436 s, and neither fits in a turn at all.
So the tail of Stage 02's phase-1 schedule cannot be tested under this harness
however many runs are attempted. Recorded as a blocker rather than improvised
around. Two things would unblock it and neither is mine to decide: a safe way to
skip the title movie, which is more than half the budget and would roughly
double the window to about 297 game-seconds covering every entry, or a longer
shell timeout if the harness permits one.
This run is discarded: frozen at t=90 with 15 stalled samples and 3 losses,
correctly flagged and not interpreted. The freeze tally is now three clean of
five, lower than the three-of-four quoted last iteration.
What stands is unchanged: zero confirmed arrivals over 300 s of verified-live
flight, about 165 game-seconds, covering the t=90 and t=120 entries. That is the
strongest statement this harness can currently support.
Correcting the previous iteration's wording. It claimed on n=1 that disabling
the periodic rescan "removes the freeze". With more runs that is too strong: the
configuration is now clean at 210, 240 and 300 seconds and frozen at 60 on a
fourth run.
The tally across configurations: the heavy probe froze at 27, 45, 83, 183 and
255 seconds; the cheap probe with a 90 s rescan froze at 183; the cheap probe
with no rescan is clean in three runs of four past 200 s. That is a real and
large improvement in survival, but it is a change in probability rather than a
fix, and the documentation now says so.
The witness handled the frozen run correctly -- losses stop at t=45 and stalls
are flagged from t=60 onward, with no contradiction between them.
Separately, the candidate search was the last Python loop over eight million
words and most of the remaining startup cost. Replaced with two numpy vector
operations; 7250 candidates found and startup is no longer the bottleneck. Boot
now dominates: about 190 s of title movie plus 35 s to flight against a 595 s
turn cap leaves roughly 350 s of observation, so boot is the only remaining
lever if longer windows are needed.
Practical rule recorded: do not treat a single frozen run as evidence. Run,
check the witness, discard the frozen ones and keep the clean ones -- about
three in four are usable now, which is workable where it previously was not.