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.
26 KiB
The throttle is a TARGET-SPEED selector — measured against the definition (2026-08-13)
Status: ✅ for the shape of the law, 🟡 for the unit scale.
The unit definition gives MinimumVelocity 100, CruisingVelocity 350,
MaximumVelocity 1200, Acceleration 600 and Deceleration 500 for
UN_f001_TCAF_DeltaSaber_T_Player (values),
but not how the game applies them. This measures it.
Method — tools/re-capture/speed_law.py:
lock onto the player entity once, then sample its own position triple in guest RAM
while holding each throttle input in turn (8 s per phase, 20 Hz). Speed is
differentiated over 1-second windows, never per sample.
Result
| phase | measured speed (1 s windows) | settles to | definition field |
|---|---|---|---|
| no throttle | 367 403 471 365 463 463 365 | ~420 | CruisingVelocity 350 |
RT held |
1041 1376 1551 1379 1596 1510 1620 | ~1 530 | MaximumVelocity 1200 |
| release | 1314 672 432 452 432 450 408 | back to ~440 | — |
LT held |
203 121 135 121 115 146 122 | ~125 | MinimumVelocity 100 |
| release | 369 451 427 425 445 390 465 | back to ~430 | — |
So the throttle selects a target speed and the craft converges to it; releasing
either trigger returns it to cruise. (Refined below: the trigger is analogue, so
the target is a continuous interpolation and these three are its endpoints.) It is not a
force model with the throttle adding thrust, which is what a reimplementation would
most likely have assumed from Acceleration/Deceleration alone. Those two fields
govern the convergence rate: the release phase falls ~1 314 → ~432 in about two
seconds (~440 units/s², against Deceleration 500) and the RT phase climbs ~420 →
~1 550 in two to three seconds (~470–560 units/s², against Acceleration 600).
🟡 The unit scale
Measured world-space speeds run ≈1.2–1.3× the definition numbers in all three regimes (cruise 1.21, maximum 1.28, minimum 1.32). The HUD, meanwhile, reads 350 at cruise — the definition value exactly. So the definition's velocity unit is the HUD's, and entity world coordinates are a constant multiple of it, close to 1.25. The spread across regimes is larger than the constant itself is precise, because the craft is manoeuvring under fire while sampled (straight-line displacement per window under-reads a curving path), so this is recorded as a measured range rather than a pinned constant.
Traps
RT/LTare analogue triggers, sovgamepad trig RT 1.0holds the throttle; the button verbhold RTis a silent no-op. The first run of this probe measured the drift of a craft nobody was flying.- Do not differentiate per sample. The guest updates the position slower than
20 Hz, so per-sample differences alternate between 0 and a double step —
0, 1519, 1985, 0, 2681…for a craft flying smoothly. - Bind late, measure fast. The player entity is not in the typed-entity scan
for the first ~15 s of a mission, and the craft dies within a few minutes if
nobody is flying it — one earlier attempt ended at
GAME OVERmid-probe.
Raw samples: captures/speed-law-throttle-phases.csv.
Turn rates: AV_* are rate caps, and _Min/_Max mean at minimum/maximum speed
tools/re-capture/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.
Control mapping, measured — LX is roll (the forward vector barely moves
under it: 3–5 °/s residual), LY is pitch (vgamepad documents LY: -1 = up,
so +1 is nose down = the PitchMinus family), and the right stick does not
steer at all (0 °/s).
| phase | measured (1 s windows) | definition |
|---|---|---|
| slow + nose down | 85 · 96 · 82 → ~87 | AV_PitchMinus_Min 75 |
| fast + nose down | 55 · 45 · 63 → ~54 | AV_PitchMinus_Max 40 |
| slow + nose up | 181 · 164 · 181 → ~175 | AV_PitchPlus_Min 150 |
| fast + nose up | 103 · 130 · 175 → ~136 | AV_PitchPlus_Max 70 |
Two claims of the field names are confirmed:
- Agility falls with speed — every rate drops when the throttle goes from
LTtoRT, so_Min/_Maxare at minimum / maximum speed, not floor/ceiling of the rate. - Pitching up is about twice as fast as pitching down, exactly as the pair 150/75 (slow) and 70/40 (fast) says.
The ~1.2× factor — ⚠️ WITHDRAWN, see "Settled rates match exactly" below
The speed law measured 1.21 / 1.28 / 1.32 × its definition values; the pitch rates measure 1.16 / 1.35 / 1.17 / 1.94 × theirs. A unit scale would have to differ between metres-per-second and degrees-per-second — it cannot explain both. A time base does: if the guest's simulated second is shorter than the wall-clock second this probe measures against, every rate reads high by the same factor. So the definition numbers are self-consistent and a reimplementation should take them at face value; these measurements confirm the shape of the law (which field governs what, and how the regimes switch), not a scale correction.
🟡 No yaw input was found. AV_Yaw_{Min,Max} (45 / 25 °/s) exists, but neither
stick yaws the craft. With MaximumBank_Normal (60) and roll on LX, a
bank-to-turn model is the obvious reading — sustained turns come from rolling and
pitching — but the yaw fields may equally belong to the AI or to an input this probe
has not driven.
Raw samples: captures/turn-law-pitch-phases.csv.
The afterburner is not on the obvious buttons — a bounded negative
The definition describes the burner without naming its input: AB_ConsumeShield_Begin
50, AB_ConsumeShield 10, and a set of AB_AV_* turn caps far below the
normal ones (roll 40 vs 125–200 °/s, pitch-up 30 vs 70–150). Notably there is no
AB_*Velocity, so the burner may not raise the speed target at all.
Two independent probes, both negative for A, B, X, LB (and LS/RS on the
first):
tools/re-capture/ab_probe.py— holdRTfor a max-speed baseline, then hold each candidate: speed stayed inside the baseline's own noise band (1 200–1 580 units/s) for every one.tools/re-capture/ab_state_probe.py— sample a window of the player object while each candidate is held and report any float that falls during the hold: nothing fell.- And the cheapest oracle of the three, needing no offsets at all: count the green
pixels of the HUD's SHIELD bar before and after each hold, on a freshly spawned
craft with a full shield.
AB_ConsumeShield_Begin 50should take a visible bite; the bar read 156, 156, 156, 157, 157 across baseline and all four buttons.
So the burner is not a simple hold on A/B/X/LB/LS/RS. What remains:
a chord (something plus RT), an input this pad cannot reach, a craft variant that
has it, or fields that belong to the AI rather than the player. Worth knowing before
anyone re-probes the obvious buttons.
(RB is the nose gun, Y the missile mount and the d-pad the tactical map — see
flight controls — so those were not held here.)
The throttle is ANALOGUE: the target speed interpolates cruise → maximum
RT is an analogue trigger, so "held" was only ever one point on a curve. Walking
it in five steps (tools/re-capture/throttle_curve.py,
2.5 s to converge then 5 s of sampling per step):
RT |
settled speed (1 s windows) | mean |
|---|---|---|
| 0.00 | 465 · 411 · 373 · 504 | 438 |
| 0.25 | 648 · 554 · 678 · 622 | 626 |
| 0.50 | 739 · 961 · 897 · 919 | 879 |
| 0.75 | 1028 · 1086 · 977 · 1283 | 1094 |
| 1.00 | 1359 · 1306 · 1517 · 1186 | 1342 |
A straight ramp. Dividing by the ~1.2 time-base factor established above, the
endpoints land on the definition's own numbers — 438/1.2 ≈ 365 against
CruisingVelocity 350, and 1342/1.2 ≈ 1118 against MaximumVelocity
1200 — and the midpoint follows: 879/1.2 ≈ 733 against the predicted
350 + 0.5·(1200−350) = 775. So
target speed = CruisingVelocity + RT · (MaximumVelocity − CruisingVelocity)
and LT mirrors it down to MinimumVelocity — measured, see below.
This also refutes a live hypothesis about the afterburner. Full RT producing
more than MaximumVelocity looked like it might be the burner; it is not — the
curve is smooth through full deflection, with no step, and the shield does not move.
Full throttle is simply full throttle.
Raw samples: captures/throttle-curve-rt.csv.
(The entity scan that locks onto the player searches changing position triples, so it only sees the craft while it still has speed — bind early, or a mission left idling drops out of the scan entirely and every tool reports "player entity not found" while the game is visibly flying.)
LT mirrors RT, and roll does NOT depend on speed
One flight, both measurements
(tools/re-capture/flight_law2.py).
The braking half of the curve
LT |
0.00 | 0.25 | 0.50 | 0.75 | 1.00 |
|---|---|---|---|---|---|
| speed | 436 | 379 | 289 | 209 | 126 |
A straight ramp down from cruise to ~126, and dividing by the ~1.2 time-base factor
the endpoints are the definition's own numbers again — 363 against
CruisingVelocity 350 and 105 against MinimumVelocity 100. So the throttle
law is symmetric:
RT held: target = Cruising + RT · (Maximum − Cruising)
LT held: target = Cruising − LT · (Cruising − Minimum)
Roll
Roll turns the craft about its own forward axis, so it has to be measured on a different row of the rotation matrix than pitch was:
| phase | rate per 1 s window | mean |
|---|---|---|
| minimum speed + full roll | 171 · 103 · 157 | ~144 °/s |
| maximum speed + full roll | 140 · 145 · 164 | ~150 °/s |
Roll shows no speed dependence — the two regimes agree inside the noise, where
pitch dropped by a third to a half between them. Against the definition
(AV_Roll_Min 200, AV_Roll_Max 125) the measured ~145 °/s is ~121 after the
time-base factor, i.e. the Max value in both regimes.
So the _Min/_Max pair does not mean the same thing for every axis: pitch
interpolates between them with speed, roll appears pinned at Max. A
reimplementation that applies one rule to all axes would get roll wrong at low
speed by ~60 %.
🟡 Caveat kept: the roll phases were 2 s of settling apart, which is marginal for a full 126 → 1 342 speed change, so "no dependence" is a measurement over a real but not perfectly separated pair of regimes.
Raw samples: captures/throttle-curve-lt.csv ·
captures/turn-law-roll.csv.
⚠️ Settled rates match the definition EXACTLY — the "time base" reading is withdrawn
The earlier turn measurements settled each phase for 1.5 s and then differentiated
from the first second. Re-running with 5 s of settle and the craft's speed
recorded at the moment the turn starts
(tools/re-capture/flight_law3.py):
| phase | speed at turn | measured rate | definition |
|---|---|---|---|
| pitch, minimum speed | 130 /s | 74.9 °/s | AV_PitchMinus_Min 75 |
| pitch, half throttle | 388 /s | 73.7 °/s | (between, see below) |
| pitch, maximum speed | 1 821 /s | 41.1 °/s | AV_PitchMinus_Max 40 |
| roll, minimum speed | 110 /s | 129.5 °/s | AV_Roll_Min 200 |
| roll, maximum speed | 1 722 /s | 149.3 °/s | AV_Roll_Max 125 |
Pitch lands on 75 and 40 — the definition's own numbers, with no scale factor.
So the ~1.2× I attributed to the emulated time base was an artefact of measuring
during the AA_* acceleration ramp with too little settle, and that explanation is
withdrawn: angular rates need no correction, and a reimplementation should use
AV_* verbatim.
What remains unexplained is only on the linear side: settled speeds still read
high and vary between runs (RT full measured 1 342 in one flight and 1 821 in
another, against MaximumVelocity 1 200), which is what a craft being shoved around
in a firefight looks like. The HUD, meanwhile, reads exactly CruisingVelocity at
neutral throttle. A clean linear measurement wants a quiet corner of a map, not this
one — until then, no cause is claimed.
Roll, re-measured with proper settles, confirms the axis difference: 129.5 at
110 /s and 149.3 at 1 722 /s — no speed dependence, both near AV_Roll_Max 125,
where pitch moved 75 → 40 across the same range. The mid-throttle pitch point does
not discriminate lerp-versus-switch, because at half throttle the craft only reached
388 /s, where an interpolation would predict ~66 °/s against a measured 73.7 — inside
this probe's noise.
Raw samples: captures/turn-law-settled.csv.
The linear discrepancy is real, and it is between WORLD units and DISPLAYED speed
Last section removed the "time base" explanation by showing settled angular rates
match the definition exactly. That leaves the linear side, and the HUD settles it:
screenshotting the speed readout at each throttle step, next to the position-derived
measurement of the same moment
(captures/throttle-curve-hud.csv):
RT |
HUD readout | position-derived | ratio |
|---|---|---|---|
| 0.00 | 350 (= CruisingVelocity) |
~447 | 1.28 |
| 0.25 | 507 | ~652 | 1.29 |
| 0.75 | 963 | ~1 141 | 1.19 |
Two things follow.
- The HUD speaks the definition's language. It reads exactly
CruisingVelocityat neutral and climbs towardMaximumVelocityas the trigger is pressed — 963 at three-quarters, against the 987 the interpolation predicts. So the throttle law stated above is confirmed in the game's own units, independent of any position sampling. - World displacement runs ~1.2× the displayed speed. Since the angular rates
need no such factor, this is not a clock effect: it is a unit difference between
the position triple and the velocity fields. A reimplementation that places
entities in world coordinates and moves them at
MaximumVelocitywill be ~20 % slow.
🟡 The ratio is 1.19–1.29 across the three points rather than a clean constant, and every sample was taken in a firefight where the craft is also being pushed. Pinning it exactly wants a quiet map or a scripted straight run; the existence and rough size of the factor is what these measurements support.
A methodological note worth keeping. This line of work went: hold a trigger → "three target speeds" → analogue interpolation → an unexplained 1.2× → "time base" → withdrawn → a unit difference confined to the linear side. Every step came from one of two moves: let a held input vary (the trigger's analogue range), or bring in an independent oracle (the HUD, the definitions, a longer settle). The wrong turn — the time base — came from explaining a number instead of first measuring the same quantity a second, cleaner way.
✅ The linear factor IS the clock — measured against the game's own mission timer
The cleanest possible version of the linear measurement: neutral throttle (HUD reads
exactly CruisingVelocity 350), sticks centred, 20 s of perfectly straight flight
(straightness = displacement/path = 1.000, so no manoeuvring contaminates it) —
tools/re-capture/cruise_ratio.py:
displacement 8 900 world units in 20.1 s -> 443.6 /s
ratio vs the HUD's 350 -> 1.267
And the game's own clock, read off the HUD across a wall-clock interval:
mission TIME 00:08.79 -> 00:46.97 = 38.18 s of game time
wall clock = 30.29 s
ratio = 1.260
1.260 against 1.267 — the same number. So the linear "discrepancy" was never a
unit difference: the mission clock runs ~1.26× faster than wall time under this
emulator, and a speed derived by dividing world displacement by wall seconds is
inflated by exactly that. World units and the displayed speed share one unit, and a
reimplementation should take CruisingVelocity/MaximumVelocity at face value —
per game second.
This supersedes the previous section's "world-unit vs displayed-speed difference".
❔ One thing does not fit, and is left open. The settled turn rates measured
74.9 and 41.1 °/s in wall time against AV_PitchMinus_{Min,Max} 75/40 — but if
game time runs 1.26× fast, a 75 °/game-second cap should have read ~94 °/wall-second.
Either that agreement was luck inside a noisy sample (the per-window rates spanned
61–96), or angular integration is frame-based where linear is time-based. The check:
re-measure pitch and convert wall→game seconds with the clock ratio, expecting ~94
if the clock explanation is universal.
✅ The clock factor applies to the ANGULAR side too — and it varies per run
The open question was whether the ~1.26× mission-clock factor explains only the
linear measurements. Measuring both in the same run settles it
(tools/re-capture/pitch_gametime.py:
HUD screenshots bracket each turn phase, so the mission clock's own advance converts
wall seconds to game seconds):
this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311
| phase | wall-time rate | ÷ 1.311 → game-time | definition |
|---|---|---|---|
| pitch, minimum speed | 88.9 °/s | 67.8 °/s | AV_PitchMinus_Min 75 (0.90×) |
| pitch, maximum speed | 53.6 °/s | 40.9 °/s | AV_PitchMinus_Max 40 (1.02×) |
So with the clock correction both land on the definition (the slow phase runs 10 %
low, consistent with the AA_* ramp being included in an 8 s window). The clock
explanation is universal: every rate the game states is per game second, and a
probe that divides by wall seconds reads high by that run's ratio.
And the ratio is not a constant of the machine — it varies with scene load: 1.260 in the earlier flight, 1.311 here. It has to be measured in the same run as whatever it is correcting, which is why this probe brackets its own phases with HUD screenshots.
Bonus confirmation from the same screenshots: at full LT the HUD reads 102,
against MinimumVelocity 100 — the throttle law's lower endpoint, in the game's
own units.
The trap that cost three runs: a dead emulator looks alive
A killed Canary leaves both its /dev/shm/xenia_memory_* image and its last
frame on the X display. So the screenshot shows a mission in progress, the memory
image still parses, and the scans quietly report 0 moving triples / "player entity
not found" — which reads like a tooling bug. pgrep -x xenia_canary does not
help: zombies match it. speed_law.require_live_emulator() now checks the process
state letter and refuses to measure a corpse, naming the stale image in its error.
⚠️ The roll result is WITHDRAWN — the probe was not isolating roll
Re-running the roll phases with 5 s settles and in-run clock brackets
(tools/re-capture/roll_gametime.py):
this run's clock: TIME 00:34.93 -> 00:45.97 = 11.04 s game in 7.98 s wall = 1.383
minimum speed: 90.6 °/wall-s ÷ 1.383 -> 65.5 °/game-s (AV_Roll_Min 200)
maximum speed: 59.1 °/wall-s ÷ 1.383 -> 42.7 °/game-s (AV_Roll_Max 125)
Two things are wrong with reading this as "roll".
- The corrected numbers — 65.5 and 42.7 — are within a few per cent of the pitch run's 67.8 and 40.9. Two different stick axes producing the same rates is the signature of a measurement that is not separating them.
- Watching a non-forward matrix row sees any rotation that moves that row, and pitch moves it as much as roll does. The forward row was the wrong thing to avoid; what is needed is the rotation about the forward axis — project the row onto the plane perpendicular to forward and track the angle of that projection.
So the earlier conclusion — "roll shows no speed dependence, unlike pitch" — is withdrawn. It rested on 2 s settles (which this session has twice shown to produce wrong answers) and on a row that mixes the axes. The two runs do not even agree with each other (144/150 then, 90.6/59.1 now), which is itself the tell.
What survives: AV_Roll_{Min,Max} are not confirmed, and the axis question —
does roll follow the same speed rule as pitch? — is open, with the correct
measurement specified above.
The clock ratio has now been measured three times in three flights: 1.260, 1.311, 1.383. It is not a property of the machine but of the moment, so any rate probe must bracket its own phases.
Roll, re-measured about the forward axis — the withdrawal is reversed ✅
The earlier roll result was withdrawn because the measurement was wrong, not the
input: watching a non-forward row of the rotation matrix sees any rotation that
moves that row, and pitch moves it as much as roll, which is why two different stick
axes produced the same rates. tools/re-capture/roll_axis.py measures rotation
about the forward axis instead — express the new up-vector in the old
(up, right) basis and take atan2(u_new·w_old, u_new·u_old), so any component
along forward (what pitch produces) is dropped by construction.
Run on stage 02 with the container-safe file pad, which matters here: the pad state
is written whole, so the stick is deflected on exactly one axis (lx=32767,
every other channel exactly 0) while the throttle trigger is held in the same write.
5-second settles, 8-second dwells, and each phase bracketed by HUD-clock screenshots
because the clock ratio is a property of the moment (1.260/1.311/1.383 previously).
| phase | swept | wall | HUD clock | per game second | definition |
|---|---|---|---|---|---|
min speed (LT) |
1 856.9° | 8.01 s | 01:20.64 → 01:30.63 (×1.247) | 185.9 °/s | AV_Roll_Min 200 |
max speed (RT) |
1 181.2° | 8.02 s | 01:37.11 → 01:46.88 (×1.218) | 120.9 °/s | AV_Roll_Max 125 |
Roll DOES depend on speed, and the withdrawn claim ("roll shows no speed
dependence, unlike pitch") is reversed. Roll behaves exactly like pitch: the rate
cap falls as speed rises, and _Min/_Max mean at minimum / at maximum speed —
the same rule the pitch work established, now confirmed on a second axis rather than
contradicted by it.
Both measurements land just under their caps (93 % and 97 %), which is the right side for a rate limit. 🟡 The shortfall is not explained: candidates are the craft not being exactly at min/max speed after 5 s, and the swept-angle sum slightly undercounting at 20 Hz. Neither is worth a claim without measuring it.
Method note: the tell that the old result was broken was two conditions agreeing
too well (pitch ≈ roll). The tell that this one is sound is that they now disagree
in the direction the definitions predict, on two independently-bracketed phases.
Data: captures/roll-about-forward-axis.csv.
Which input drives which axis — and the yaw answer ✅
tools/re-capture/axis_probe.py holds one channel at a time (file pad, so every
other channel is exactly zero) and decomposes the result into all three rotation
components at once, rather than measuring one axis through a row that moves under any
rotation — the flaw that once made roll and pitch produce identical numbers.
The row labelling is measured, not assumed. entities2 pins row 2 = forward
against velocity; the probe pins the other two by comparing world-Y in flight:
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 D3D convention an earlier run assumed, which means
that run's yaw and pitch columns were swapped — under the correct labels its
ly+ reading of 154.1 °/wall-s is pitch, which is what a left-stick Y axis
should do.
| input | roll | yaw | pitch | |
|---|---|---|---|---|
rx right-stick X |
0.0 | 0.0 | 0.0 | nothing |
ry right-stick Y |
0.0 | 0.0 | 0.0 | nothing |
LB |
0.0 | 0.0 | 0.0 | nothing |
RB |
0.0 | 0.0 | 0.0 | nothing (it is the nose gun) |
lx left-stick X |
209.8 | ~0 | ~10 | roll |
ly left-stick Y |
~0 | ~0 | 154.1 | pitch |
These zeros are trustworthy, unlike the previous attempt's: the four unknown inputs were measured first, each passing a liveness check, and the run aborted the moment the craft stopped moving instead of reporting the clean zeros a destroyed craft produces.
So no pad input yaws the craft directly. AV_Yaw_* (45/25) exists in the unit
definitions but nothing on the right stick or the shoulders drives it — the earlier
❔ "no input found" is upgraded from "we could not find one" to "the remaining
candidates measurably do nothing". The natural reading is that yaw is a
consequence of banking rather than a commanded axis, which a reimplementation
should model as such.
🟡 Not covered: the d-pad (the tactical map) and the face buttons (fire /
weapon select). Neither is a plausible flight axis, but neither was measured here.
Data: captures/axis-probe-rows-pinned.csv.