With the per-entity searches refuted at word and bit level, the question became
which object owns the counter. Sampling +-0x200 around it across a 4->8
transition: the control interval moved 0 of 256 words, and the step moved nine -
the counter plus four words holding ASCII '4' -> '8' NUL-padded, and four
pointers into 0xbcad2xxx that swap with them. Read live at HUD 008, all four
character slots hold '8'.
So the neighbourhood is the HUD's rendered text for this counter, which reframes
the address: it is the HUD widget's value rather than "the mission's own
objective counter" as this file called it.
Recorded against that, because it is already measured: there is no separate
mission-side copy moving on the same step. ob_hunt scans all of guest memory and
requires a match across two transitions, and it left exactly one address.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The short-run strategy paid off: both counter steps happen in the first minutes
of flight, so one run yielded two complete passes before anything froze or was
killed. Pass 1 scanned at counter 4 and filtered on 4->8, leaving 29 of 710
candidates; pass 2 scanned at 8 and filtered on 8->12, leaving 2 of 197. The
intersection of the two survivor sets is EMPTY.
So no per-entity bit, in either polarity, anywhere in -0x400..+0xC00 around the
position triple, has a population that tracks REMAINING OB. With the word-level
refutation that closes the "objectives are marked in the entity object" family as
far as this method reaches.
Also recorded: 16 of pass 1's 29 survivors are the SAME word, pos+0x0250, with
sixteen different bits - the signature of a shared value rather than a flag, and
the same offset the word-level sweep found shared by 12 attackers. And a caveat
kept rather than smoothed: pass 2's B-sample HUD read 8 while RAM read 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The fourth run froze 9 seconds into the watcher's window, in flight, and the
probe built for that moment showed the healthy-run baseline and nothing else: one
pair, the same poller on the same object VA as every healthy run, only the thread
handle differing. No new (thread, object) pair appeared.
So the hypothesis the probe was built to catch is refuted - the freeze is not a
guest thread looping on KeWaitForSingleObject timeouts against ONE object - while
the CPU signature is unchanged from the gdb run: 1255 ticks over 10 s, 401 in the
TimerQueue thread and 292/280 in two guest threads.
What survives is stated as two specific blind spots of the instrument rather than
a shrug: the waits may cycle over DIFFERENT objects, which resets the streak and
makes them invisible to a same-object counter; or they may SUCCEED rather than
time out, which leaves a timeout counter nothing to count and would fit the
kernel-log evidence of a self-suspending worker cycling thousands of times
successfully.
Next is a v2 that counts calls per thread per second regardless of object or
result. The freeze lottery paid out on the first attempt this time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The gdb route works, and the recipe is written down: run-canary execs $XENIA_BIN,
so a wrapper that execs "gdb --args <real binary>" keeps the lockfile, the flags
and the process name (gdb forks and execs the real binary, so ps -C xenia_canary
still finds the inferior) while satisfying ptrace_scope=1 by being the parent.
The Release binary is not stripped - 26595 symtab entries - so frames have names.
The handle SIGSEGV/SIGBUS/SIG32-35 lines are mandatory: xenia uses SIGSEGV for
guest memory watches and the RT signals for thread suspend.
A run froze after ~4 minutes of flight, screen still "flight" rather than GAME
OVER, and all 79 threads had backtraces. EVERY ONE is in a wait - guest threads
in KeWaitForSingleObject / NtWaitForSingleObjectEx / SelfSuspend, the GPU command
processor parked idle, the main thread in poll().
It is nevertheless burning 1253 ticks per 10 s: 403 in the TimerQueue thread
(nanosleep inside TimerThreadMain) and 290 + 274 in two guest threads that the
backtrace shows blocked in KeWaitForSingleObject. A thread genuinely blocked
cannot burn 28% of a core, so those two are CYCLING - a timed wait that expires
and is re-entered - with the timer thread servicing them hot. Three samples
minutes apart show identical frames.
That refines the earlier "the guest is spinning, not deadlocked": the CPU burn is
real but it is in the WAIT PATH inside the kernel layer, not in guest code. The
shape is an event that never gets signalled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
With the pilot finally shooting, the experiment completed both halves. Sample A
at counter 12 over 120 entities gave 2 offsets where exactly 12 entities share a
value; the counter then went 12 -> 11 and NEITHER survived. So within +-0x400 of
an entity's position triple there is no 4-byte word whose shared-value population
tracks REMAINING OB.
The limits are recorded as part of the result, because they bound it: the test
asks which entities share an EXACT 32-bit value, so a single bit ORed into a word
that also carries health or a timer would never show up - a bit-level version of
the same differential is the follow-on. Anything outside the window, or on
entities that entities2 cannot see (it types by position CHANGING, so stationary
objectives are invisible), is untested too, and the populations differed a lot
between samples - 120 against 194.
Separately: REMAINING OB went 12 -> 11, the first decrement of this whole
investigation, while pilot.py logged 411 fire=1 samples and the HUD reached YOU
KILLED WARPLANES 0003. Stated carefully - it does NOT show the counter counts
kills, since an earlier run had the hostile population fall by a third with no
movement; it shows some kills close something the counter tracks.
Two robustness fixes: ob_flag retries an empty entity sample (one void run was
caused by exactly that), and Pad releases everything on interpreter exit - a
file-backed pad PERSISTS after its writer dies, so a tool killed mid-press would
leave a button held and the game would walk through menus on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The control this file never had: a run with the Kernel channel on, analysed WHILE
STILL FLYING, has 2738 refused resumes on one pair - more than the frozen run's
1171. The target's own lines show why. The game runs a self-suspending worker
(NtSuspendThread on itself, a manager thread resumes it, thousands of times), and
a self-suspended thread is not host-suspended, so the host Resume legitimately
returns false EVERY cycle: 3115 refusals against 3115 resumes.
The error is named rather than buried: the warning's commit says "~7 times in a
normal boot" and this file generalised that from boot to gameplay, where the
number is thousands. The 150x anomaly was an artefact of the baseline. The
zero-CPU threads go with it - the healthy run has four of those too.
What the instrumented reproduction DOES establish is sharper than the lead was.
The last kernel event in 690000 lines is "Thread F8000204 self-suspending", with
self-suspends 3116 against resumes 3115 - but the resumer never issues another
NtResumeThread at all, so nothing was dropped in flight; every thread stopped
together. And the guest is SPINNING, not deadlocked: over 10 s while frozen the
main thread is in state R gaining 409 ticks and guest threads gain ~680 in total
while making not one kernel call. So it is guest code waiting on something in
guest memory, and the next question is which guest PC.
Two corrections fall out: "the log stopped growing" is not a freeze detector (it
goes quiet for 25 s in normal flight), and 0xbdb59668 held the counter again -
4 of 6 runs now.
freeze_report.py makes the analysis repeatable, and refuses to answer "did this
thread ever run" when the Kernel channel was off rather than reporting a false NO.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
A Stage 02 run froze at TIME 01:02 with a radio line caught mid-word. The
emulator was alive at ~200% CPU with its main thread in state R, two screenshots
six seconds apart were byte-identical, and the log ended in a spin: 1171 of the
run's 1200 "host resume was refused" lines are the single pair F80002AC ->
F8000240, starting at the line immediately after F80002AC is created, and the log
never grows again. The commit that added that warning records what normal looks
like - about 7 in a whole boot - so this is a 150x anomaly on one pair rather
than noise. F8000240 itself appears exactly once outside the spin, at creation,
and calls nothing.
Eleven of the frozen process's 79 host threads have zero CPU, four of them
consecutive late-created guest threads - the same signature as the lost resume
that c1b57f93b fixed for the title screen. That fix IS in this build, so either
there is a second window in that race or this only looks alike.
The inference is named as one: nothing here maps a guest handle to a host tid, so
"the zero-CPU threads are the ones being resumed" is a reading of two consistent
observations. And the refusals could equally be the game's reaction to a worker
stuck for another reason - log_mask=13 has the Kernel channel disabled, so not
one of F8000240's waits is visible. The next experiment is written down
concretely: reproduce with LOG_MASK=12 LOG_LEVEL=3 and map the handle to a tid.
Also caps ob_hunt's survivor listing at 40 - an aborted run printed all 21482 and
buried the line that mattered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
One filter to select (004 -> 008, 35897 -> 7), a second on a transition it was
NOT selected by (008 -> 012, 7 -> 1), leaving exactly one address; then three
live paired readings, RAM/screenshot/RAM, all agreeing with the HUD.
The address is the same one run 1 reported. That does NOT reverse yesterday's
refutation and the entry says so explicitly: runs 2 and 3 read a hard 0 there on
an allocated page while the HUD counted, so "it is there in every run" stays
refuted. What is withdrawn is the stronger claim that the number was meaningless
- it recurs exactly, in 2 of the 4 runs measured, and run 3's amber candidate
0xbdb49668 sits one 64 KB page below it at the identical page offset 0x9668. The
practical rule is therefore: try 0xbdb59668, check it against the HUD, re-scan
when it reads 0.
Why run 3 failed and run 4 did not is also recorded, because it is a method
lesson rather than luck: the evidence was always in the first four minutes of the
stage, and the earlier runs simply could not look often enough - every HUD
reading cost a human round trip, so the 008 step went by between two of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
ship_capture_window.sh polls for the flight screen and presses F10 the moment it
appears rather than after a fixed sleep. One run gave three results.
First, the mission ran with ZERO crashes through t+152s - the first clean mission
run, where the three before it ended at 13243 / 11898 / 11497 - and it renders
and plays: player ship, starfield, full HUD, no dialog.
Second, the capture armed and wrote its file, so the mechanism works in-mission.
Third, and against expectation, the file holds NO ship geometry. 329KB, 181
deduped draws: 180 of them share a single vertex shader, all stride=28 vcount=3
prim=8 at full-screen coordinates, plus one full-screen quad, and not one draw
has positions outside the 1280x720 rectangle. The budget is not the limit -
kShipCaptureBudget is 8000 and only 181 distinct (vbase, WVP) pairs were seen -
and the scene was definitely drawing. The known-good capture from an earlier
session is 2.9 MB.
Fourth, a correction to the previous commit. It said the cache is "REFUTED as the
cure". Too strong: this run used the IDENTICAL complete cache as tut4 and
produced 0 crashes against tut4's 11497. What four runs support is that a
complete cache is not SUFFICIENT to prevent the storm and that run-to-run
variance dominates a 3-run comparison - not that the cache does nothing.
Next step is static: compare this capture's shape against the known-good one to
find why the 3D draws never reach CaptureShipDrawForRE.
resume_reliability.sh counts, per boot, whether the title's loader thread became
the CALLER of a kernel call. Counting boots where the press reached the title:
before the fix 1 of 5 ran the loader; after it, 5 of 5. Three of the post-fix
boots were driven far enough to classify the screen and all three reached the
MAIN MENU, each with the same shape - title at 216-252s, 40 kernel calls by the
loader, 6 ResolvePath reads. The failing boots before had zero of both, every
time.
Caveat kept in the doc: pre-fix runs were interleaved with compiles and a race is
load-sensitive, so the counts corroborate the mechanism rather than carry it.
Also fixes a self-inflicted confound. Two runs came back 'title_at_s=none' with
the emulator log ending in 'Killed', which I first read as the title detector
being too strict. It was not: an earlier copy of the script was still running
because 'pkill -f resume_reliability.sh' had matched the shell running it rather
than the script, and each of that copy's iterations kills the emulator BY NAME -
outside run-canary's lockfile. The harness now takes its own lockfile and refuses
a second copy, and the note records that pkill -f matches your own shell.
Two findings, the second only visible because of the first.
1. The harness has always had kernel logging switched off. log_mask DISABLES
categories (Kernel=1, Apu=2, Cpu=4, Gpu=8), so the long-standing --log_mask=13
meant Kernel+Cpu+Gpu off; kernel calls also log at Debug while log_level
defaults to Info. Seeing one needs BOTH LOG_MASK=12 and LOG_LEVEL=3, and no
boot log this project has taken ever contained a kernel call. boot_menu.sh now
takes LOG_MASK / LOG_LEVEL / EXTRA_FLAGS. A whole boot at Debug with Kernel on
is 23 MB, so the default was costing far more than it saved.
2. With that on, a captured failure shows the (A) handler doing everything right:
XamUserGetXUID(0, 7, ...)
NtCreateEvent(...)
ExCreateThread(..., entry=821748F0, ..., 00000001)
ExCreateThread Active: Thread Initially Suspended,
XThreadF80000CC (1F) Stack: 70880000-70900000
NtResumeThread(F80000CC, ...)
and the thread then never executing. Measured two independent ways: it makes
ZERO kernel calls - it appears 13 times in the log and every one is as an
ARGUMENT, never as the calling thread, while five other threads make 31905
calls after the resume - and its host thread has 00:00:00 CPU time while the
process sits at 546% and has burned 37 minutes of CPU in 6:46 wall.
A spinning thread burns CPU. This one has not run at all.
So the chain is: press delivered -> handler runs -> thread created suspended ->
resumed -> never scheduled. Input, the cache-flush crash and the game's own logic
are all excluded. A lost resume is a race, which is the first explanation that
fits the ~1-in-3 success rate.
Not settled: where the resume is lost, and no successful boot has been captured
with kernel logging to compare against.
"The title screen ignores (A)" is withdrawn. First-divergence across three boots
of the same binary says otherwise.
A slot-(1F) guest thread is spawned BY the press: exactly once per run,
immediately after the keydown, same stack base 70880000-70900000 in both runs
that got one, and never at all in the run that never accepted a press - which
rules out a periodic worker starting around the same time.
prm6, reached the menu: (A) at line 6498, (1F) at 6500, 6 ResolvePath after
opt2, stuck on the title: (A) at line 1287, (1F) at 1288, 0 ResolvePath after
opt, stalled before title: no (A) ever, no (1F) thread at all
In the successful run the loader immediately reads six paths out of the on-disc
cache and the menu appears. In the failed run the same thread starts and performs
no file I/O ever again. Total ResolvePath for the three boots is 90/84/78 - the
successful run's extra six are exactly the ones after the press, so the boots are
otherwise identical in I/O.
Refuted as the cause: the cache-flush std::out_of_range. All four of today's runs
have zero GUEST-THROW, zero CRASH DUMP and zero Access Violation, and the guest
stays alive throughout with its keystroke-poll counter climbing past 15000.
Next probe is neither input nor the crash: what the (1F) thread waits on.
The tie-break needs a fourth measured screen with a tied group, and OPTIONS is
three d-pad steps from the main menu, avoiding the NEW GAME hang. It was not
reached.
Advancing past the title is intermittent: 1 success in 3 attempts today with the
same binary, profile and procedure.
Attempt 1 stalled before the title existed. The guest was alive - RSS steady at
598MB and the keystroke poll counter climbing past 15000 - but the screen was
black and screen_children.py found ONLY the splash object resident. So that
failure is not a presentation problem: the game never constructed the title.
Being able to separate "stalled before building the screen" from "built but not
presenting" is new, and it is what the reader is for.
Attempt 2 reached the title at 265s and refused four presses - (A) at 0.25s,
0.25s and 0.5s, START at 0.3s - every one of them logged as DELIVERED to the
guest. Third independent confirmation that input delivery is fine and the game
simply does not act on the press. Waiting longer does not help either: presses
were spread from 10s to about 4 minutes after the title appeared, while the
earlier successful run's press landed within seconds.
Records the log as evidence and states the next probe: it has to be guest-side
now, on whatever consumes the keystroke, since the input path is excluded.
Chasing why (A) does not advance the title turned up four harness bugs, one real
root cause, and a refutation of that root cause as the explanation.
FIXED, each verified:
* boot_menu.sh sent the emulator stdout to /dev/null, which is why none of the
rest was visible. It now keeps a log and prints its path.
* skip_intro.sh probed pixel (625,618) for the green (A) glyph - a 1280x720
coordinate. screenshot returns the 1279x675 game surface, where that point
is on the copyright line and reads (8,17,31). Symptom: a 600s TIMEOUT with
the title on screen. Now classified with screen_id.py; next boot printed
"TITLE at 239s -> A".
* skip_intro.sh no longer taps through the movies. Making that press real (the
vgamepad fix) was a REGRESSION: the boot then reached the title in 90s and
that title accepted nothing. menu_draw_capture.sh records the same finding
independently.
* boot_menu.sh signs in whichever profile exists instead of a hard-coded XUID.
ROOT CAUSE FOUND: naming a XUID with no profile behind it opens a sign-in
dialog, xam_dialogs_shown_ goes to 1, and IsUIActive() then discards every
XamInputGetKeystrokeEx. Measured 8,388,601 swallowed calls in one boot with the
pad log showing presses arriving normally throughout. Only B13EBABEBABEBABE
exists on disc; the script asked for E0300000EFBEA3D4. Signing in the real
profile takes the swallow count 8,388,601 -> 0.
AND REFUTED: that is not why the title is stuck. With a valid profile and zero
swallowed keystrokes, a single (A) on the title that ends the boot still does
nothing. The dialog bug was real and worth fixing; it is not the explanation.
Next probe is specific: --log_mask=13 records no kernel calls, so it is not even
known whether the game polls at the title. A log on the SUCCESSFUL
XamInputGetKeystrokeEx return, mirroring the one already on the swallow path,
answers it in one boot.
The plan was to separate ptlogo_back2eff from ptlogo_back2eff5 (same 1133x280,
declaration 20 vs 18, either side of ptlogo_back2 at 19) by their resting fade
alphas. Carried out, and refuted: both quads come back at alpha FF, and reading
the bundle again, both elements REST at 255 under this project's own max-dwell
rule — back2eff5's longest hold is t=74 at 255, and the 192 I had quoted is a
later, shorter keyframe, not its resting value. The ambiguity stands.
What the colour did buy is worth more than the tie-break: the first check of the
fade/keyframe decode against the RUNNING GAME rather than against another parse.
Every static element draws at exactly the resting alpha the bundle predicts, and
the only two quads whose alpha moves between consecutive frames (88->88/86->87
and 3C->38) are the rotating effect pair and the PRESS (A) glow — the two things
visibly animating on screen. Nothing had confirmed before that the alpha channel
of a keyframe is what drives that throb.
Two results, one of which closes the navigation problem the last three
iterations kept hitting.
**The title states.** The hypothesis was that the attract-loop title is a
non-interactive presentation that omits the PRESS (A) plate. Half right:
* the state distinction is REAL — a single (A) on the title that ends the boot
opens the main menu, 2 of 2 in independent runs, one of which never pressed
F10; the title the attract loop returns to accepts nothing, not (A), START, B,
BACK, X or Y, across dozens of delivered presses;
* the proposed tell is REFUTED — capturing the draws in both states in one run
gives 13 quads at identical rects, ptbtn00 and ptbtn00f included. The two are
identical to the renderer and different only to the guest.
So there is now a reliable route to the menu: first title after boot, one tap.
**The main menu's paint order**, captured with it. Its sprites are GP_TITLE
build 5's, and ptframe1/ptframe2/ptbtn01f land within 4 px of their declared
resting placements — an independent placement check on an untouched bundle.
It does NOT settle the ordering question, and the entry says so: build 5 lists
its background at indices 1-2, so declaration order and "background first"
predict the same sequence here. Same failure mode as GP_READY_ROOM/GP_OPTIONS.
What it does establish is that the title's disagreement is not a decode
artefact — same pak, same engine, one build that follows its table and one that
does not.
Chasing the title-screen crash into the emulator's own diagnostics turned it
from a mystery into a controlled experiment, and knocked over two things this
corpus said yesterday.
Withdrawn #1: "the fault address 0x1_0000000C is a pointer with a stale high
word". The crash dump prints r25 = 0x0000000C, clean. Xenia maps the guest's
4 GiB at host 0x1_00000000, so that IS guest address 12. The guest dereferenced
the small integer 12.
Withdrawn #2: "with --mem_watch=false the crash does not happen at all", which
named the crash-oracle handoff's suspect #1 as measured. It was confounded —
every --mem_watch=false run had also had a warm cache. Held cold, the throw
happens with the probe off (2 437 crash dumps). mem_watch is eliminated for this
crash.
What it actually is: the access violation is the guest's own `throw` RETURNING,
because this build logs guest C++ exceptions and continues rather than
unwinding. So the event is the throw, and with --cache_throw_diag=true the guest
names it: std::out_of_range, from the cache-manager flush, with a deque of 38
entries (37 distinct, one duplicated) against a 37-key map, every one of them
absent from the flush's snapshot but present in the live map — the TOCTOU race
the logger's own message describes.
And the new, useful part: the trigger is the on-disc cache. Complete cache, no
throw (2 runs). Directory moved aside or half-rebuilt, throw ~100 s into the boot
(3 runs, including one that threw with NO access violation behind it — which is
why crash dialogs are the wrong thing to count). A suspect in that bisection plan
now costs a `mv` and two minutes instead of a mission.
Three findings and two corrections, all against the same capture.
Corrections first, because one of them invalidates numbers already written down:
* **the running screen is build 4, not build 7.** `screen info` defaults to the
largest build; the game runs a different one, and the two disagree on sprite
sizes (ptlogo1 822x100 vs 919x113 — the capture says 915x115). Every
declaration index in the previous entry was against the wrong table. The
conclusions survive unchanged; the indices are restated.
* **the ship capture does NOT skip these UI draws.** This entry justified a new
hook partly on "it requires an f32x3 position stream, which a 2D quad does not
have". These quads have one: armed on the title, xenia_ship_capture_01.log
holds 1150 records of which 965 are the UI sprite shader. The new hook is
still the right tool — frame brackets, submission order, texture identity —
but it was justified with a wrong reason.
Findings:
* **no depth in the geometry.** Attribute 0 is k_32_32_32_FLOAT, so every quad
carries a Z, and every Z is 0.00000. Submission order is the whole ordering.
* **no ordering field in the bundle.** Every word of build 4's declaration table
dumped (committed as evidence): the unknowns are constant. The placement
region's per-group lead word — never read before — is 0 for all 24 groups, and
the region is followed straight by the RATC child stream, so no table hides
behind it.
* **the batching rule**, which is what makes the order readable: a draw holds
one or two quads, and a two-quad draw always holds two elements adjacent in
paint order that share a texture page. The engine flushes when the page
changes; it does not sort by page (pages alternate).
Reproduced in an independent run — same order, same rects — which is the second
observation the CONFIRMED bar asks for.
BACKLOG's UI-ordering item ended at "the next step is no longer static — it is a
per-draw capture of the title screen showing the order the game submits". That
capture now exists.
The order, in submission order: a full-screen background layer, a rotated effect
pair, a second full-screen layer, ptlogo_back2eff + ptlogo_back2, ptlogo1 +
ptlogo_tm, ptlogo2, ptcopyright, and the PRESS (A) BUTTON plate — declaration
indices 13, 22|24, 23, 0, 11, 1, 28, and then two elements that are not in that
build at all.
Two more candidate orderings die on it (keyframe start time, resting-keyframe
time), and one structural fact reframes the item: the visible screen composites
TWO bundles — build 7 plus the one-element build 2 that is the button — so no
single build's element table can be the paint order whatever its order. The
button's quad lands at (384,551) against a declared rest of (383,550), which is
a one-pixel agreement between the static placement decode and the running game
on a bundle nothing had checked.
INDEX's "the screen's draw list is the bundle's declaration table (back-to-front)"
is demoted in place rather than edited away.
Still open and said so: the rule that produces the order; three of twelve quads
unidentified; one screen only.
To separate "a 1 s burst never reaches the steady rate" from "a per-axis
multiplier", measure inside ONE hold: successive 0.25 s windows of a single 3 s
press, holding speed, attitude and starting conditions constant by construction.
rep0 rate 16 59 325 209 130 322 238 110 310 151 183
rep1 rate 52 181 231 246 236 169 368 195 181 255 182
Not usable. At 0.25 s the windows do not contain enough guest updates to average,
so rate and speed both swing 3x window to window -- the same aliasing that once
manufactured a rate-vs-speed curve, reappearing at finer resolution. The first
window is lowest in BOTH repeats, which is what a ramp would look like, but the
sequence never plateaus, so the signal cannot be separated from the sampling. No
claim either way.
Widening the window does not rescue it: 0.5 s averages well enough, but a hold
long enough to contain several 0.5 s windows bleeds speed -- and speed is the
variable under test. The two effects are entangled at this observation rate.
So the residual needs a different INSTRUMENT, not another script. Live-RAM polling
samples an unsynchronised snapshot; the question wants the craft's angular
velocity as the guest computes it, once per frame. That is a Canary-side hook --
the same shape as the existing F10 ship-capture patch -- and the rebuild toolchain
already makes it cheap. Recorded as the recommendation rather than attempted as a
seventh variation of the same measurement.
Burst design repeated with the HUD clock bracketed (01:01.86 -> 02:10.61, ~x1.26)
and roll measured in the same flight.
pitch 87.0 @ 85 82.8 @ 308 47.0 @ 1077 min:max 1.85
roll 125.0 @ 76 119.0 @ 291 82.6 @ 1023 min:max 1.51
SHAPE CONFIRMED on both axes: the rate interpolates between _Min (at minimum
speed) and _Max (at maximum speed), matching to 1.6% for pitch (vs PitchMinus
75/40 = 1.88) and 5.6% for roll (vs 200/125 = 1.60). Both ratios are
clock-independent so they stand regardless of the conversion, and pitch's
re-confirms ly+ = pitch-MINUS (reproducing 1.82 from the previous run).
ABSOLUTE SCALE DOES NOT MATCH, and not in the same direction:
pitch measured/predicted = 1.15, 1.21, 1.07 consistently OVER
roll = 0.62, 0.64, 0.60 consistently UNDER, very flat
A clock error cannot explain this -- it would move both axes together and these
go opposite -- so the discrepancy is per-axis. Candidates, neither measured: a 1 s
burst not completing the angular-acceleration ramp (would under-read; fits roll's
flat 0.62, not pitch's excess), or an unidentified per-axis multiplier. Left as an
open question with the separating test named: 1 s versus 3 s bursts at the same
throttle, where the ramp hypothesis predicts the longer burst reads higher.
Also noted: the roll run correctly REFUSED on a WEAK row pin after the pitch
bursts left the craft tumbled -- and roll is immune to that labelling, which the
guard's own message says, so ALLOW_WEAK_PIN=1 is its documented exception. A guard
that states its own exception is worth having.
The design the sweep could not provide: settle the throttle, measure the settled
speed, pitch for ONE second so speed barely moves inside the burst. Three
throttles, two repeats, row pin CONFIDENT, fresh flight.
LT min burst speed ~105 rate 113.6, 109.5 deg/wall-s
cruise ~383 100.2, 88.4
RT max ~1483 52.2, 70.5
Rate falls monotonically with speed -- 111.5 -> 94.3 -> 61.4 -- at three KNOWN,
SETTLED speeds instead of smeared across a bleeding one.
The decisive comparison needs no clock. Absolute rates depend on the run's clock
ratio, but the min:max RATIO cancels it:
measured min:max = 1.82
AV_PitchMinus_Min/Max 75/40 = 1.88 -> 3.0% apart
AV_PitchPlus_Min/Max 150/70 = 2.14 -> 15.1% apart
Two conclusions, neither resting on a clock measurement:
- _Min/_Max really do mean "at minimum / at maximum speed", with the rate
interpolating between them: shape confirmed to 3%.
- ly+ drives pitch-MINUS, not plus. The craft has asymmetric pitch authority
(75/40 down vs 150/70 up) and the ratio picks the pair cleanly.
Absolute magnitudes remain open: this run did not bracket the HUD clock, so
deg/GAME-second cannot be computed from it, and picking a ratio that makes the
numbers fit would be circular. The probe now screenshots the clock at both ends.
Also: fly_stage.sh now waits for the TAKE-OFF load too. Guarding only the stage
load left a run pressing A into a black screen and then reporting "player entity
not found" from a game that never reached flight.
Fresh flight, row pinning CONFIDENT (margin 0.413), one sweep and nothing before
it. axis_probe now REFUSES to measure on a WEAK pin (ALLOW_WEAK_PIN=1 overrides)
since it is a precondition, not a warning: roll is immune to the up/right
labelling but pitch and yaw are not.
Clock x1.26. Binned by speed, both in game units, against the linear
interpolation of AV_PitchPlus_Min 150 (at MinimumVelocity 100) to _Max 70 (at
MaximumVelocity 1200):
speed ~435 measured 100.8 predicted 125.6
speed ~572 113.8 115.7
speed ~709 126.3 105.7
speed ~846 83.1 95.7
speed ~983 72.7 85.8
Supported: the magnitudes (73-126 measured vs 86-126 predicted) and a falling
high-speed end. NOT supported: the interpolation law. Scatter is +-25%, the two
fastest bins hold 1 and 2 windows (the first moments before the speed bled), and
the slowest bin misses in the wrong direction.
The flaw is structural, not statistical: a sweep DRIVEN by the speed bleeding
cannot dwell at either extreme, which is exactly where the law is most testable.
What would settle it: hold a settled throttle and pitch for ~1 SECOND, so speed
barely moves inside the burst and each burst gives one honest (speed, rate) point;
repeat at LT / neutral / RT for three clean points at known speeds. Recorded as
the next design rather than attempted as a fifth variation of the same sweep.
Fitting rate against instantaneous speed produced a tidy "rate rises with speed"
relationship, with speeds up to 4795 when the craft's maximum is 1200. It is
entirely an artefact: 20 Hz polling is faster than the guest updates these fields,
so a per-read delta is either exactly zero (no update yet) or a whole frame's
worth divided by a fraction of a frame. 111 of 352 reads were zero on BOTH
channels -- position and attitude update on the same frame, so the two are
perfectly correlated, and dividing each by the short wall dt produced the
correlation out of nothing.
Fix: aggregate over windows spanning many frames (0.5 s). A sum of |delta| over
such a window is right however the updates fall inside it.
This does NOT affect the swept-total probes (roll_axis.py, rate_probe.py) -- they
already summed over the whole dwell, immune for the same reason. Only per-sample
instantaneous rates were ever wrong, so no earlier number moves.
The windowed re-run is NOT yet claimed as a result. It gives plausible magnitudes
but still shows rate rising with speed, against the definition's PitchPlus_Min 150
> _Max 70, and it has two disqualifiers: it ran on an instance where the craft was
already tumbling from the previous sweep, so pinning reported "WEAK -- craft may
not be level", and the sweep started mid-range rather than at maximum. A clean
answer needs a fresh flight with pinning CONFIDENT and nothing before it. Since
what is in doubt is precisely what _Min/_Max mean, a measurement through a
doubtful instrument cannot settle it.
Both datasets kept, the bad one labelled, because the aliased curve is a good
example of what a manufactured correlation looks like.
Measured pitch with the rows properly pinned, against this craft's own disc caps
(AV_PitchPlus_Min 150, AV_PitchPlus_Max 70):
min speed (LT) 1391.0 deg / 8.00 s, clock x1.326 -> 131.1 deg/game-s vs 150
max speed (RT) 989.0 deg / 8.05 s, clock x1.318 -> 93.2 deg/game-s vs 70
A rate 33% ABOVE a cap is not a finding, it is a broken instrument. The HUD speed
is in the same bracketing screenshots that give the clock, so read it:
slow phase 102 -> 18
fast phase 1193 -> 589
The speed is NOT constant during the dwell -- pitching halves it in 8 seconds.
The cap is speed-dependent, so as the craft slowed its cap rose, and an 8-second
average necessarily lands between the max-speed cap and a mid-speed one. The 133%
is entirely the instrument.
This also weakens the roll result committed earlier: same method, so 120.9 vs
AV_Roll_Max 125 is CONSISTENT but is not a tight test -- the true cap could be
lower and still produce that average. Said plainly in the doc rather than left
standing as a clean confirmation. Min-speed figures are less affected; there is
little speed left to lose.
Proper fix, not yet done: dwell ~1-2 s so speed barely moves, or sample HUD speed
continuously and fit rate against INSTANTANEOUS speed -- which yields the whole
rate-vs-speed curve instead of two points.
Separately this is a flight-model finding: TURNING COSTS SPEED, steeply, with the
throttle still at maximum. A reimplementation treating the throttle as a speed the
craft simply holds will be wrong during manoeuvres.
The probe now pins which non-forward row is up and which is right, instead of
taking the D3D convention on faith:
row world-Y means: [0.469, 0.883, -0.000] forward = row 2
-> up = row 1, right = row 0 CONFIDENT
That is the OPPOSITE of the assumption the previous run used, so that run's yaw
and pitch columns were swapped -- under the correct labels its ly+ reading of
154.1 deg/wall-s is PITCH, which is what a left-stick Y should do.
Measured with the unknown inputs FIRST, each passing a liveness check:
rx 0.0 0.0 0.0 ry 0.0 0.0 0.0
LB 0.0 0.0 0.0 RB 0.0 0.0 0.0
lx roll 209.8 ly pitch 154.1
These zeros are trustworthy where the previous run's were not: the craft was
verified alive between inputs, and the probe aborted the moment it stopped moving
rather than reporting the clean zeros a destroyed craft produces (it did abort,
after lx+, which is why lx/ly are carried from the earlier run rather than
re-measured).
So NO PAD INPUT YAWS THE CRAFT. AV_Yaw_* (45/25) exists in the definitions but
nothing on the right stick or the shoulders drives it, which upgrades the old
"yaw: no input found" from a failure to find one into a measurement that the
remaining candidates do nothing. The natural reading is that yaw is a consequence
of banking rather than a commanded axis.
Not covered, and not claimed: the d-pad (tactical map) and the face buttons
(fire/weapon select). Neither is a plausible flight axis; neither was measured.
New probe (axis_probe.py) decomposes every held input into all THREE rotation
components at once, instead of measuring one axis at a time through a non-forward
matrix row -- the flaw that once made roll and pitch produce identical numbers.
For previous rows (f,u,w): roll = atan2(u.w_old, u.u_old), and forward's rotation
toward each of the other two rows gives the remaining pair.
Stage 02, file pad, full deflection on exactly one channel at a time:
lx+ roll 209.8 b 0.9 c 10.1 deg/wall-s
ly+ roll 0.0 b 154.1 c 0.0
rx+ roll 0.0 b 0.0 c 0.0
ry+ roll 161.1 b 87.0 c 37.1
LB/RB all zero
What this supports: lx = ROLL, cleanly (~0 on both other channels), agreeing with
the independent roll measurement. ly drives ONE axis, cleanly.
What it does NOT support, and I am not claiming:
- WHICH axis ly drives. entities2 measures row 2 = forward against velocity, but
rows 0 and 1 are labelled up/right by the D3D convention rather than by
evidence, and yaw/pitch SWAP if that is wrong. Roll is immune (rotation of
either non-forward row in their shared plane is roll either way).
- anything about rx/ry/LB/RB. The run ended on GAME OVER: full-deflection spin in
a live combat mission gets the craft destroyed, and the only symptom is "0
player candidates" AFTERWARDS, so late rows may be post-death. rx+ reading all
zeros and ry+ reading mixed are exactly what a dying craft would produce.
So "yaw: no input found" is NOT resolved. Both gaps are now written into the
probe's header with what would fix them: a liveness check between inputs, and
pinning up-vs-right against world Y.
Measured about the FORWARD axis (roll_axis.py), which is the fix the withdrawal
specified: express the new up-vector in the old (up, right) basis and take
atan2(u.w_old, u.u_old), so the component along forward -- what pitch produces --
is dropped by construction. The old probe watched a non-forward matrix row, which
sees any rotation that moves it, and that is why two different stick axes produced
the same numbers.
Stage 02, file pad, single-axis holds (lx=32767 with every other channel exactly
0, trigger held in the same write since the pad state is written whole), 5 s
settles, 8 s dwells, each phase bracketed by HUD-clock screenshots:
min speed (LT) 1 856.9 deg / 8.01 s wall, clock x1.247 -> 185.9 deg/game-s
vs AV_Roll_Min 200
max speed (RT) 1 181.2 deg / 8.02 s wall, clock x1.218 -> 120.9 deg/game-s
vs AV_Roll_Max 125
So roll behaves exactly like pitch: the rate cap falls as speed rises and
_Min/_Max mean "at minimum / at maximum speed". The withdrawn claim that roll
shows NO speed dependence is reversed, and the axis rule is now confirmed on a
second axis instead of contradicted by it.
Both land just under their caps (93% and 97%), the right side for a rate limit.
The shortfall is NOT explained and no claim is made about it: candidates are the
craft not being exactly at min/max speed after 5 s, and the 20 Hz swept-angle sum
undercounting.
The tell that the old result was broken was two conditions agreeing too well. The
tell that this one is sound is that they disagree in the direction the definitions
predict, on two independently bracketed phases.
roster_target had flagged a contradiction: S10, a STORY stage, still fielded an
unharvested unit, which the "story campaign complete at 68 units" claim did not
account for. S10 was simply never flown. Flying it settles it:
UN_e005_ADAN_ElanTypeQ_Margras is in the snapshot, 144 rows, and the merge takes
the file 68 -> 69 units, 9 393 -> 9 537 rows, 7 115 -> 7 204 defaulted-on-disc
values, with 154 disc cross-checks agreeing and 0 disagreeing. roster_target now
reports S10: 0 missing, and every remaining gap is a CHALLENGE stage
(S24/S25/S27/S28/S29).
A much simpler way to fly a story stage, replacing the save-editing route
(tools/re-capture/fly_stage.sh): poke ONE word -- 0x828F40C0 = 0x0001FFFE marks
stages 1-16 cleared -- and MISSION SELECT will launch any of them. Nothing is
written to disc, so there is no save to back up and restore, and TRAP 1 from the
old recipe (launch_mission.sh silently loading the last-used slot) cannot happen.
Route, learned by screenshotting each step: MISSION SELECT -> pick stage -> A ->
mission briefing (A: Continue) -> READY ROOM -> TAKE OFF -> flight. The READY
ROOM carries an "EXTRA" watermark, which is the static analysis's mission-KIND
= 3 ("EXTRA" config section, docs/re/challenge-mission-gate.md section 4) visible
on screen -- an independent confirmation of that field's meaning.
Also recorded: a snapshot taken at the BRIEFING yields 0 runtime objects. Unit
definitions are instantiated at stage load proper, so the snapshot has to wait
for flight; the briefing screen is too early.
Static only. Last commit left "REQUIREMENT is a bit index into a progress
bitfield" with the space unidentified. It is the achievement space, and both
halves are now readable off the disc and the executable.
- GamePart_Debriefing (0x8218CF38-0x82191B18) awards them: sub_8218F9A8 walks
the on-disc ACHIEVEMENTS_REQUIREMENTS list (tables.pak #16, schema 744c0519),
and for entry index n tests bit n, evaluates the entry when clear, and sets
the bit when satisfied. The list is literally ACHIEVEMENT01..ACHIEVEMENT24 --
24 entries, which is exactly where the challenge gate splits word A from
word B.
- The XEX carries the definitions: XACH at .pe 0x8FBCBC, 36-byte records
{id, name_id, unlocked_desc_id, locked_desc_id, image_id u32, gamerscore u16,
pad, flags u32, 16 zero bytes}, strings from one XSTR per language (English is
table #5). tools/xach_dump.py parses it. SELF-CHECK: the 24 gamerscores sum to
exactly 1000, the retail total -- a wrong stride does not land on a round 1000.
- The two sources agree on ORDER independently: the requirement types
ShootDownAircrafts 1000/10000, ShootDownShips 100, ShootDownWeight MegaTons,
GetAllWeapons and GetAllAchievements line up with ids 19-24 exactly as XACH
names them. So bit n <-> achievement n+1 is evidence, not inference. (Those
last two are requirement TYPES, not debug cheats, despite how they read.)
- Corollary: TimeAttack's REQUIREMENT 16 -- the one value that sits in direct
value-before-key adjacency, so it survives IDXD dedup -- is bit 16 =
achievement 17, "Solar System Defense Award", i.e. finish the story campaign.
The other five values (25-29) are >= 24 and so index word B, a second flag
space, plausibly a challenge-clear chain. Still 🟡.
REFUTED, from the last commit: the stores to +1956 in 0x822AF278 / sub_822C8748
are NOT this singleton. That object comes from 0x822CEB30, checks a +2652 flag
and stores string POINTERS at +1956/+2024 -- and a pointer ANDed with 1<<n is
meaningless as a gate. So nothing in the image writes this singleton's +1956
field-wise, and where the mask persists (save vs Xbox profile) is open. XEX
imports are by ordinal, so absent XamUser* strings are not evidence either way.
Static only. Bounding each GamePart's code block by its factory creator thunk
(id -> creator recovered for 22 of 24 registrations at 0x8280C000-0x8280F800)
puts GamePart_ChallengeMission at 0x82187E60-0x8218CF10. Resolving every string
that block references gives the screen's config schema, and the record itself is
on disc -- tables.pak schema 54a10697, one copy per language, English entry #64.
Six missions: TimeAttack (record Time), ScoreAttack (record Points) and
Extra01..Extra04, each with MISSION_ID / REQUIREMENT / REQUIREMENT_DESC /
THUMBNAIL / STAGE_DESC / NEW_STAGE and a NORMAL_BUTTON / GRAY_BUTTON pair -- so
the screen always lists all six and greys out what is not earned.
THE GATE (0x82189970-0x821899D8), read off the code:
REQUIREMENT absent -> available
REQUIREMENT == "Always" -> available
else n = atoi(REQUIREMENT)
n == 0 -> locked
n < 24 -> test bit n of the word at singleton+80
n >= 24 -> test bit (n-24) of the word at singleton+1956
The singleton is 0x821707C0 (lazy, global 0x828F48BC). So availability is one
bit in a progress bitfield and REQUIREMENT is a bit INDEX -- not a stage number,
score or difficulty.
Values per mission are 🟡: the pool's numeric tokens are 16/25/26/27/29 and
24/28 already appear earlier as font metrics, so they would be deduped -- which
fits 24..29 but IDXD dedup makes positional pairing unsound here, so it is
recorded as a hypothesis, not a table.
Negative: the requirement TEXT is not in GP_CHALLENGE.pak (TextIndex over it =
0 entries; its only prose is embedded font copyright). Its PATH is a per-language
branch the loader does not currently reproduce.
Next: three stores to +1956 sit in 0x822C7DD0 / 0x822C8748, the same region as
the save serializer 0x822C00E8 -- if the bits are save-backed, a hand-written
save unlocks all six challenge missions and the last 42 units become one run.
Static-only (no emulator, no pad input). Three findings, each with its own
evidence:
- The disc holds exactly 29 StageResource records in three families --
S01-S16 story, S18-S23 tutorial (all bg=Original), S24-S29 challenge, plus
Test. That is 16 + 6 + 6 + 1, matching weapon.tbl's stage01..16 /
tutorial01..06 / challenge01..06 key set exactly. S17 does not exist.
GP_CHALLENGE.pak has 0 IDXD objects -- it is the menu screen; challenge
missions reuse GP_MAIN_GAME_E.pak's records.
- The GamePart id table is at 0x820A1630 (29 ids). Indices are confirmed by
the image's own RegisterToFactory<N, class silph::GamePart_*> text, not by
position: GP_CHALLENGE = 26, GP_TUTORIAL = 25, GP_BUNK = 10.
- The stage loader selects its config section from a mission-KIND field at
object+144: 3 -> EXTRA, 5|6 -> CHALLENGE, else FILE (two independent sites,
0x82184df0 and 0x82185ed0; two more classify {3,5,6} as one group). The
constructor sets it to 0 and every write inside the class only clears it,
and no immediate 3/5/6 store to it exists image-wide -- so the kind is
supplied by the launching GamePart, never derived from the stage number.
That last point is a mechanism (unproven) for why patching the save's stage
field to 27 kills the load: the record is a challenge stage but the kind stays
FILE. Names an untried, zero-cost discriminator -- try stage 18-23.
Also flagged, not resolved: roster_target says S10 (a STORY stage) still
fields an unharvested unit, which contradicts the "story campaign complete"
claim by one unit.
Re-running roll with 5 s settles and in-run clock brackets:
this run's clock: TIME 00:34.93 -> 00:45.97 = 11.04 s game in 7.98 s wall = 1.383
min speed: 90.6 deg/wall-s /1.383 -> 65.5 deg/game-s (AV_Roll_Min 200)
max speed: 59.1 /1.383 -> 42.7 (AV_Roll_Max 125)
The corrected numbers are within a few per cent of the PITCH run's 67.8 and 40.9 —
two different stick axes cannot produce the same rates, so the probe is not
separating them. Cause: watching a non-forward matrix row sees any rotation that
moves that row, and pitch moves it as much as roll. The correct measure is rotation
ABOUT the forward axis (project the row onto the plane perpendicular to forward and
track that angle).
So "roll shows no speed dependence, unlike pitch" is withdrawn: it rested on 2 s
settles and a row that mixes axes, and the two runs disagree with each other
(144/150 then, 90.6/59.1 now). AV_Roll_{Min,Max} are not confirmed and the axis
question is open.
The clock ratio is now measured three times in three flights: 1.260, 1.311, 1.383 —
a property of the moment, not the machine, so every rate probe must bracket its own
phases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
pitch_gametime.py brackets each turn phase with HUD screenshots, so the mission
clock's own advance converts wall seconds to game seconds within the same run:
this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311
pitch @ min speed 88.9 deg/wall-s /1.311 -> 67.8 deg/game-s vs AV_PitchMinus_Min 75
pitch @ max speed 53.6 /1.311 -> 40.9 vs AV_PitchMinus_Max 40
Both land on the definition (the slow phase 10% low, consistent with including the
AA_* ramp in an 8 s window), so the clock explanation covers angular motion as well:
every stated rate is per GAME second.
The ratio is not a machine constant — 1.260 in the earlier flight, 1.311 here — so it
must be measured in the same run as whatever it corrects. Bonus: the same shots show
the HUD reading 102 at full LT against MinimumVelocity 100.
Also documents the trap that cost three runs: a killed Canary leaves both its shm
image and its last frame on screen, so a dead emulator looks alive and the scans
report "0 moving triples" like a tooling bug. pgrep -x matches zombies, so
speed_law.require_live_emulator() checks the process state letter and refuses to
measure a corpse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Screenshotting the HUD speed readout at each throttle step, beside the
position-derived measurement of the same moment:
RT 0.00 HUD 350 (= CruisingVelocity) position ~447 ratio 1.28
RT 0.25 HUD 507 position ~652 ratio 1.29
RT 0.75 HUD 963 position ~1141 ratio 1.19
So (a) the HUD speaks the definition's units — exactly CruisingVelocity at neutral,
963 at three-quarters against the 987 the interpolation predicts — confirming the
throttle law in the game's own numbers without any position sampling; and (b) world
displacement runs ~1.2x the displayed speed. Since settled angular rates need no such
factor, this is a unit difference between the position triple and the velocity
fields, not a clock effect: a reimplementation moving entities at MaximumVelocity in
world coordinates will be ~20% slow.
Also fixes speed_law.find_player: a mission holds more than one *_Player object and
at least one never moves, so the finder now samples each candidate twice and keeps
the one that displaces. Locking onto the static one is what produced a run of exact
zeros while the game was visibly flying.
🟡 The ratio is 1.19-1.29 rather than a clean constant and every sample was taken in
a firefight; pinning it wants a quiet map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Re-measured with 5 s of settle per phase and the speed recorded at the moment the
turn starts (flight_law3.py):
pitch @ 130/s 74.9 deg/s vs AV_PitchMinus_Min 75
pitch @ 1821/s 41.1 vs AV_PitchMinus_Max 40
roll @ 110/s 129.5 vs AV_Roll_Min 200
roll @ 1722/s 149.3 vs AV_Roll_Max 125
Pitch lands on the definition's own numbers with NO scale factor, so the ~1.2x I
attributed to the emulated time base two iterations ago was an artefact of
differentiating during the AA_* acceleration ramp with too little settle. That
explanation is withdrawn: AV_* can be used verbatim.
What remains is only on the linear side — settled speeds still read high and vary
between runs (RT full: 1342 in one flight, 1821 in another, vs MaximumVelocity 1200),
consistent with a craft being shoved around in a firefight. The HUD reads exactly
CruisingVelocity at neutral. A clean linear measurement needs a quiet map; no cause
is claimed until then.
Roll re-measured with proper settles confirms the axis difference: no speed
dependence, both regimes near AV_Roll_Max, where pitch moved 75 -> 40.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
One flight, two measurements (flight_law2.py, binding early so the moving-craft scan
can see the player).
LT curve: 436, 379, 289, 209, 126 units/s across LT 0.00 -> 1.00 — a straight ramp,
whose endpoints after the ~1.2 time-base factor are CruisingVelocity 350 and
MinimumVelocity 100. So the law is symmetric:
RT: target = Cruising + RT * (Maximum - Cruising)
LT: target = Cruising - LT * (Cruising - Minimum)
Roll (measured on a non-forward matrix row, since roll turns about the forward axis):
~144 deg/s at minimum speed and ~150 at maximum — no speed dependence, where pitch
dropped by a third to a half between the same regimes. After the time-base factor
that is ~121, i.e. AV_Roll_Max 125 in BOTH regimes.
So _Min/_Max does not mean the same thing for every axis: pitch interpolates with
speed, roll appears pinned at Max. A reimplementation applying one rule to all axes
would get low-speed roll wrong by ~60%.
Caveat recorded: the two roll phases were 2 s of settling apart, marginal for a
126 -> 1342 speed change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
RT is an analogue trigger, so "held" was one point on a curve. Walking it 0.00 ->
1.00 (throttle_curve.py) gives a straight ramp: 438, 626, 879, 1094, 1342 units/s.
Dividing by the ~1.2 time-base factor, the endpoints land on the definition's own
numbers (365 vs CruisingVelocity 350; 1118 vs MaximumVelocity 1200) and the midpoint
follows, so
target speed = CruisingVelocity + RT * (MaximumVelocity - CruisingVelocity)
which refines the earlier "selects one of three targets" reading: those three are the
curve's endpoints.
It also refutes the standing afterburner hypothesis that full RT is the burner: the
curve is smooth through full deflection with no step, and the shield does not move.
The LT half is not measured yet — the entity scan needs the craft moving when it
runs, so a mission left idling drops out of it. Bind early.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
turn_law.py pins the speed regime with a throttle, holds a stick axis and
differentiates the craft's own forward vector over 1-second windows.
slow + nose down ~87 deg/s (AV_PitchMinus_Min 75)
fast + nose down ~54 (AV_PitchMinus_Max 40)
slow + nose up ~175 (AV_PitchPlus_Min 150)
fast + nose up ~136 (AV_PitchPlus_Max 70)
So agility falls with speed (_Min/_Max are at minimum/maximum speed, not rate
bounds) and pitching up is ~2x pitching down, exactly as the field pairs say.
Control mapping measured: LX is roll (forward vector barely moves, 3-5 deg/s), LY is
pitch (+1 = nose down per vgamepad's LY: -1 = up), and the right stick does not steer
at all.
The ~1.2x overshoot seen in the speed law appears again here (1.16-1.35x), and a
unit scale cannot explain both m/s and deg/s — a TIME BASE can: if the guest's
simulated second is shorter than the wall-clock second the probe measures against,
every rate reads high by the same factor. So the definition numbers are
self-consistent and these measurements confirm the shape of the law, not a scale.
Recorded 🟡: no yaw input found (AV_Yaw_* exists but neither stick yaws), which with
roll on LX and MaximumBank_Normal points at a bank-to-turn model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
speed_law.py locks onto the player entity once and samples its position while
holding each throttle input, differentiating over 1-second windows.
no throttle -> ~420 (CruisingVelocity 350)
RT held -> ~1 530 (MaximumVelocity 1200)
LT held -> ~125 (MinimumVelocity 100)
release -> back to cruise, from either direction
So the throttle SELECTS a target speed rather than adding thrust — which is what a
reimplementation would most likely have assumed from Acceleration/Deceleration
alone. Those govern the convergence rate instead: ~440 units/s^2 measured on
release (Deceleration 500) and ~470-560 under RT (Acceleration 600).
Recorded as 🟡: measured world speeds run ~1.2-1.3x the definition numbers in all
three regimes while the HUD shows the definition value exactly (350 at cruise), so
world coordinates are a constant multiple (~1.25) of the definition's velocity unit;
the spread is wider than the constant is precise because the craft manoeuvres while
sampled.
Three traps documented: RT/LT are analogue triggers (the button verb is a silent
no-op and the first run measured an unflown craft), per-sample differentiation
aliases against the guest's update rate (0, 1519, 1985, 0, 2681 for smooth flight),
and the player entity only enters the typed scan ~15 s in while the craft dies within
minutes if nobody flies it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
S08 gave the last two story-stage units — UN_be005_ADAN_SpaceFortress and
UN_mn500_ADAN_FloatingMine — plus six S08 asteroid collision meshes: 68 units,
9 393 rows, 7 115 defaulted-on-disc values. Every unit any story mission fields is
now read (689 more disc cross-checks, 0 disagreements).
Probe recorded as a negative: setting Game Status (GHAD +48 and its header mirror
+0x18) to 2 = STATE_GAME_CLEAR leaves both menus unchanged — the title still shows
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS and EXTRAS still shows only
MISSION SELECT / MOVIE THEATER / BACK. So the EX/challenge rosters are not gated on
that field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
S03, S06, S09, S13, S15, S16 (plus the earlier run): 21 -> 60 units,
963 -> 6 071 defaulted-on-disc values, 8 241 rows. Disc cross-checks agree with
0 disagreements throughout.
- UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore are harvested (HP 10 000,
Size_X 500, Size_Radius 250, MaximumVelocity 450) WITHOUT the boss ever being on
screen: definitions are instantiated at stage load, so a unit only needs to be in
the roster. That is far weaker than what the draw-capture work requires.
- The extra stages are not reachable via the save's stage field: stage 27 boots to
the title and then the emulator exits during the load, as do the other S24..S29
entries, while story stages 1-16 all load. The field addresses the story campaign
only; EX/challenge rosters need the Challenge-mode menu path.
Remaining 50 units are almost all EX variants in S24/S25/S27/S28/S29; the only
story-stage stragglers are UN_mn500_ADAN_FloatingMine and
UN_be005_ADAN_SpaceFortress in S08.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.
It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.
Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.
Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
unit_runtime.py can only place fields the disc values (it scores triples against
disc records): 58 of 153 from one snapshot. data/unit_definition_layout.txt came
from the title's loader instead, so it places all 159 — including the fields no
disc record sets, which is the Route-B target.
tools/re-capture/unit_dump_layout.py reads every field of every live definition
object with that layout and keeps the discipline: a field the disc DOES value is a
check, not a new value. Over two snapshots (19 objects): 700 cross-checks agree,
0 disagree.
fields placed per object 58 of 153 -> 159
rows over those 19 units 609 -> 2 736
defaulted-on-disc values (whole file) 1 059 -> 2 351
Two traps recorded: the layout table's offsets are DECIMAL while the solver CSV
prints hex (parsing as hex fails the cross-check on everything — which is how it
announced itself), and angle fields can carry a prefix (AB_AA_PitchPlus is still an
angle, so the AV_/AA_ test must match anywhere in the name).
Flagged: UN_f201_TCAF_Tanker's object is not byte-identical between the two
missions — per-mission override or a runtime-mutated field; needs a third snapshot
to separate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Unit definitions are instantiated per stage, so the parked "coverage grows by
visiting missions" limit was blocked on progress. With the save's stage field
solved, any story stage can be flown from a hand-edited save and snapshotted.
First harvest (a Night Ravens mission, 027 objectives, different roster):
units with runtime values 21 -> 26
csv rows 1 827 -> 2 143
defaulted-on-disc values 963 -> 1 059
New: UN_e004_ADAN_ElanPlus_N, UN_e006_ADAN_Vindicator_Margras,
UN_f002_TCAF_DeltaSaber_W, UN_f002_TCAF_DeltaSaber_W_Player (a second player craft)
and UN_mn040_Asteroid_Big (47 defaulted values, Size_Z = 2000).
Two traps recorded with the fix: launch_mission.sh presses A on the PRESELECTED
slot, which is the last-used one (03 here), so a probe in slot 01 is ignored — the
first attempt flew stage 02 again and the solver correctly found 0 new units; patch
every slot or confirm the highlighted slot by screenshot. And back up before
patching: the mid-run backup already held the probe, so slot 01 was restored from
the earlier pristine copy (md5 142b4f43…, verified byte-identical).
Token file for the solver: idxd_tokens <GP_MAIN_GAME_E.pak> Generic — 110 UN_*
records, the disc's full unit count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
The decoder's central unstated assumption — a block's index buffer sits
immediately before its vertex buffer (`vb - idx_count*2 - pad`, pad <= 3) — was
also the prime suspect for the residual anchor misses, since a capture-proven
`e106_eng_02_l` block was rejected outright. Measured it instead of assuming:
- extended the F10 ship capture to log each draw's index buffer (base, count,
min/max index) and to key its de-dup on the index range, so every draw batch
is recorded rather than only the first;
- `examples/capture_ib_truth.rs` places each drawn buffer in the container by its
dumped positions and scores the capture against our decode.
Stage_S02, 42 drawn buffers placed: our idx_count == the sum of the draw's index
batches for 42/42, the batch union covers the vertex pool exactly for 42/42, and
all 30 single-block cases sit at pad <= 3 (20 at pad 0, 10 at pad 2). The other
12 are grouped pools, where one index pool serves the whole group. So the layout
holds, the decoded index count is exact, and eng_02_l died on the connectivity
gate (since replaced by the winding gate) — not on index location. The shipped
exact-coverage rule is independently confirmed.
The recorded "capture indices=21 vs our 246" disagreement was an artefact of the
old de-dup key: 21 was the first of two batches, 21 + 225 = 246. Any conclusion
from a pre-2026-08-13 capture's `indices=` or `vbase - ibase` is about one batch,
not about the block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Applying the map to turrets, fighters, an attacker and a station (UNIT records
rather than VESSEL) gives 406 field values agreeing with the disc and 0
disagreeing across 11 objects, so one layout serves both schemas.
Two consequences the four-value signature could not give: the map IDENTIFIES an
object -- 0xbd3b6a00 is UN_bf001_TCAF_SchlosBase (14 agree, 0 disagree), not
UN_be005_ADAN_SpaceFortress (12 agree, 2 disagree on Color_R/Color_G) -- and
variants are provably identical, with ArrowHead/_EX4/_EX5 at 48/48, Turret/_EX4
at 51/51 and three DeltaSaber player variants at 49/49.
121 defaulted fields now have runtime values. FiringRange (0 in all 11) and
ShieldRatio (1 in all 6) look like true constants; HQRatio, ThrusterRatio and
AttackCraftPoint vary per unit and are recorded as such.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchoring each key string to the first store AFTER the accessor call makes the
extraction exact: 159 fields, 125 of them floats, and verify_fieldmap now checks
every one against the live objects -- 154 agree with the disc, 0 disagree.
The last ten mismatches taught the rule: angle fields are degrees on disc and
RADIANS at runtime (AV_PitchPlus_Max 3.6 -> 0.0628319; MaximumBank_Normal 60 ->
1.0472), an exact conversion across every angle field and unit.
101 defaulted fields now have runtime values: angle limits default to 3.6 degrees,
afterburner/turn-attack fields to 0, FiringRange to 0 (matching the loader's miss
value), Size_Y to that unit's Size_X, and the ratio family is per-unit rather than
constant (HQRatio 0.2 or 1, DefencePoint 0.1 or 0.25, ...).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dumping 512 words instead of 96 finds exactly the same 4 of ~50 disc values, for
all five correlated units, so the rest of the record is not in this structure.
Also corrects the reason recorded last commit: get_f32 is NOT unreliable on
default-heavy records -- it resolves 46-57 numeric fields per unit, which is what
made the emptiness of the correlation measurable in the first place. The one
automated hit (YawDragFactor -> +0x0c) remains false: +0x0c holds the integer 2
as a denormal and collided with the float 2.0.
Next probe is a RAM-wide search for a unit-distinctive value, not a bigger window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live definition objects carry no name, so identity comes from the values:
unit_signatures.rs prints (HP, Size_X/Y/Z) for every disc unit and vessel, and a
live object is the record whose known fields it reproduces. 7 of 14 match a disc
record outright; the other 7 match nothing because their records default a field
-- 18 of 23 vessel records are missing at least one, nearly always Size_Y.
Matching each single-default record against the live object that reproduces its
remaining fields resolves 13 values (Destroyer 200, Cruiser 600/700, ASFrigate
80, ADAN Destroyer 300, Acropolis 400, ISCMissile 300, plus _Inv/_EX variants).
Every one equals that unit's Size_X, so the engine's own parsed definitions
confirm the statically-derived inheritance rule.
Limits stated: variants share one live object, and the ...Ratio/...Count family
is not resolved -- their offsets in the live object are still unknown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>