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.
705 lines
35 KiB
Markdown
705 lines
35 KiB
Markdown
# 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](captures/unit-runtime-fields.csv)),
|
||
but not **how** the game applies them. This measures it.
|
||
|
||
Method — [`tools/re-capture/speed_law.py`](../../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`/`LT` are analogue triggers**, so `vgamepad trig RT 1.0` holds the throttle;
|
||
the button verb `hold RT` is 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 OVER` mid-probe.
|
||
|
||
Raw samples: [`captures/speed-law-throttle-phases.csv`](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 `LT`
|
||
to `RT`, so `_Min`/`_Max` are *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`](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`](../../tools/re-capture/ab_probe.py) — hold `RT`
|
||
for 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`](../../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 50` should 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](flight-controls-runtime.md) — 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`](../../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`](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`](../../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/throttle-curve-lt.csv) ·
|
||
[`captures/turn-law-roll.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`](../../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`](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`](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 `CruisingVelocity`
|
||
at neutral and climbs toward `MaximumVelocity` as 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 `MaximumVelocity` will 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`](../../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`](../../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`](../../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`](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`](captures/axis-probe-rows-pinned.csv).
|
||
|
||
|
||
## ⚠️ Angular-rate probes measure a MOVING speed — the dwell bleeds it
|
||
|
||
Measuring pitch with the rows properly pinned gave, for
|
||
`UN_f001_TCAF_DeltaSaber_T_Player` (whose own disc values are
|
||
`AV_PitchPlus_Min` **150°**, `AV_PitchPlus_Max` **70°**):
|
||
|
||
| phase | swept | clock | per game-s | cap |
|
||
|---|---|---|---|---|
|
||
| min speed (`LT`) | 1 391.0° / 8.00 s | ×1.326 | **131.1** | 150 |
|
||
| max speed (`RT`) | 989.0° / 8.05 s | ×1.318 | **93.2** | 70 — **133 % of cap** |
|
||
|
||
A rate 33 % *above* a cap is not a finding, it is a broken instrument, so read the
|
||
HUD speed off the same bracketing screenshots that gave the clock:
|
||
|
||
```
|
||
slow phase 102 -> 18
|
||
fast phase 1193 -> 589
|
||
```
|
||
|
||
**The speed is not constant during the dwell — pitching bleeds it hard**, halving it
|
||
in 8 seconds. The rate cap is itself speed-dependent, so as the craft slowed its cap
|
||
*rose*, and the 8-second average necessarily lands between the max-speed cap and a
|
||
mid-speed one. The 133 % is fully explained by the instrument.
|
||
|
||
**This weakens the roll numbers above as well.** They were taken the same way, so
|
||
`120.9` vs `AV_Roll_Max` `125` is *consistent* but is **not a tight test** — the true
|
||
max-speed roll cap could be lower and still produce that average. The min-speed
|
||
figures are less affected (there is little speed left to lose).
|
||
|
||
**How to measure it properly** (not yet done): keep the dwell to ~1–2 s so the speed
|
||
barely moves, or sample the HUD speed continuously and fit rate against
|
||
*instantaneous* speed rather than assuming the endpoint. The second is strictly
|
||
better and gives the whole rate-vs-speed curve rather than two points.
|
||
|
||
**And a flight-model finding in its own right:** *turning costs speed*, steeply —
|
||
1 193 → 589 under 8 s of full pitch, with the throttle still held at maximum. A
|
||
reimplementation that treats the throttle as setting a speed the craft simply holds
|
||
will be wrong during manoeuvres. Data:
|
||
[`captures/pitch-rate-speed-bleed.csv`](captures/pitch-rate-speed-bleed.csv).
|
||
|
||
|
||
## ⚠️ Polling faster than the guest updates manufactures a curve
|
||
|
||
Trying to fit rate against *instantaneous* speed (`rate_curve.py`, one long hold so
|
||
the speed-bleed sweeps the range) produced a beautifully clean result that is
|
||
entirely an artefact:
|
||
|
||
```
|
||
speed 0- 599 rate 29.4 speed 1199-1798 rate 279.8
|
||
speed 599-1199 rate 174.9 speed 1798-2398 rate 317.5
|
||
```
|
||
|
||
Rate rising with speed, and speeds to **4 795** when the craft's maximum is 1 200.
|
||
The cause: **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. In that run **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 a tidy
|
||
correlation out of nothing.
|
||
|
||
**Fix: aggregate over windows spanning many frames** (0.5 s here). The sum of
|
||
`|Δ|` over 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, which is immune for the same reason. Only
|
||
per-sample instantaneous rates were ever wrong.
|
||
|
||
🟡 **The windowed re-run is not yet a result.** It gives plausible magnitudes
|
||
(speed 320–935, rate 80–149 °/wall-s) but still shows rate *rising* with speed,
|
||
against the definition's `AV_PitchPlus_Min` 150 > `_Max` 70. Two disqualifiers: it
|
||
ran on an instance where the craft was already tumbling from the previous sweep, so
|
||
the row pinning reported **WEAK — craft may not be level**, and the starting speed
|
||
was mid-range rather than maximum. A clean answer needs a **fresh flight**, pinning
|
||
CONFIDENT, one sweep, nothing before it. Recorded as an open question rather than a
|
||
finding — the definitions' `_Min`/`_Max` meaning is exactly what is in doubt, so a
|
||
measurement taken through a doubtful instrument cannot settle it.
|
||
|
||
Data: [aliased, for reference](captures/rate-curve-aliased-BAD.csv) ·
|
||
[windowed](captures/rate-curve-windowed.csv).
|
||
|
||
|
||
## The clean sweep: magnitudes agree, the LAW does not follow 🟡
|
||
|
||
Fresh flight, row pinning **CONFIDENT** (margin 0.413), one sweep and nothing before
|
||
it — the conditions the previous attempt lacked. Clock 01:16.08 → 01:41.46 (×1.26).
|
||
Binned by speed, both converted to game units:
|
||
|
||
| speed (game) | measured °/game-s | linear interpolation of the caps |
|
||
|---|---|---|
|
||
| ~435 | 100.8 | 125.6 |
|
||
| ~572 | 113.8 | 115.7 |
|
||
| ~709 | 126.3 | 105.7 |
|
||
| ~846 | 83.1 | 95.7 |
|
||
| ~983 | 72.7 | 85.8 |
|
||
|
||
(prediction = `AV_PitchPlus_Min` 150 at `MinimumVelocity` 100 → `_Max` 70 at
|
||
`MaximumVelocity` 1200, interpolated linearly.)
|
||
|
||
**What this supports:** the magnitudes are right — measured 73–126 °/game-s across
|
||
speeds 400–1 050 against a predicted 86–126 — and the high-speed end falls, as a
|
||
speed-dependent cap should.
|
||
|
||
**What it does not support:** the interpolation *law*. The scatter is ±25 %, the two
|
||
fastest bins hold only 1 and 2 windows each (they are the first moments before the
|
||
speed bled), and the slowest bin disagrees in the wrong direction (100.8 measured vs
|
||
125.6 predicted). A sweep that is *driven* by the speed bleeding cannot spend long at
|
||
either extreme, which is exactly where the law is most testable.
|
||
|
||
**The design that would settle it**, and why this one cannot: hold a *settled*
|
||
throttle, pitch for only **~1 second**, and read the rate — the speed barely moves
|
||
inside a 1 s burst, so each burst yields one honest `(speed, rate)` point. Repeat at
|
||
`LT` / neutral / `RT` for three clean points at known speeds, instead of one smeared
|
||
sweep. Data:
|
||
[`captures/pitch-rate-curve-clean.csv`](captures/pitch-rate-curve-clean.csv).
|
||
|
||
|
||
## ✅ Short bursts at settled speeds: the law's SHAPE is confirmed, clock-free
|
||
|
||
The design the sweep could not provide: settle the throttle, measure the settled
|
||
speed, then pitch for **one second** so the speed barely moves inside the burst.
|
||
Three throttle settings, two repeats each, row pin **CONFIDENT**, fresh flight.
|
||
|
||
| throttle | burst speed (wall) | rate (°/wall-s) |
|
||
|---|---|---|
|
||
| `LT` min | ~105 | 113.6, 109.5 |
|
||
| none, cruise | ~383 | 100.2, 88.4 |
|
||
| `RT` max | ~1 483 | 52.2, 70.5 |
|
||
|
||
**Rate falls monotonically with speed** — 111.5 → 94.3 → 61.4 — measured at three
|
||
*known, settled* speeds rather than smeared across a bleeding one.
|
||
|
||
**And the decisive comparison needs no clock at all.** 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
|
||
```
|
||
|
||
So two things follow, neither resting on a clock measurement:
|
||
|
||
1. **`_Min` / `_Max` really do mean "at minimum / at maximum speed"**, with the rate
|
||
interpolating between them — the shape is confirmed to 3 %.
|
||
2. **`ly+` drives pitch-MINUS**, not pitch-plus. The craft has asymmetric pitch
|
||
authority (75/40 down versus 150/70 up), and the ratio picks the pair cleanly.
|
||
|
||
🟡 **Absolute magnitudes are still open** — this run did not bracket the HUD clock, so
|
||
deg/*game*-second cannot be computed from it, and choosing a ratio that makes the
|
||
numbers fit would be circular. The probe now takes clock screenshots at both ends so
|
||
the next run closes it. Data:
|
||
[`captures/pitch-burst-settled-speeds.csv`](captures/pitch-burst-settled-speeds.csv).
|
||
|
||
|
||
## Both axes, three settled speeds: shape ✅, absolute scale 🟡
|
||
|
||
Repeat of the burst design with the HUD clock bracketed (01:01.86 → 02:10.61) and
|
||
roll measured in the same flight. Clock ≈ ×1.26.
|
||
|
||
| axis | min speed | cruise | max speed | min:max | definition ratio |
|
||
|---|---|---|---|---|---|
|
||
| pitch | 87.0 °/game-s @ 85 | 82.8 @ 308 | 47.0 @ 1 077 | **1.85** | `PitchMinus` 75/40 = **1.88** |
|
||
| roll | 125.0 @ 76 | 119.0 @ 291 | 82.6 @ 1 023 | **1.51** | `Roll` 200/125 = **1.60** |
|
||
|
||
**The shape is confirmed on both axes** — the rate interpolates between `_Min` (at
|
||
minimum speed) and `_Max` (at maximum speed), matching to **1.6 %** for pitch and
|
||
**5.6 %** for roll. These ratios are **clock-independent**, so they stand regardless
|
||
of the conversion. Pitch's ratio also re-confirms `ly+` = **pitch-minus** (1.88)
|
||
rather than plus (2.14), reproducing the earlier run's 1.82.
|
||
|
||
**The absolute scale does not match, and not in the same direction:**
|
||
|
||
```
|
||
pitch measured / predicted = 1.15, 1.21, 1.07 (consistently OVER)
|
||
roll measured / predicted = 0.62, 0.64, 0.60 (consistently UNDER, and very flat)
|
||
```
|
||
|
||
**A clock error cannot explain this** — it would scale both axes the same way, and
|
||
these go opposite. So the discrepancy is per-axis. Two candidates, neither measured:
|
||
|
||
- a **1-second burst may not complete the angular-acceleration ramp**, which would
|
||
*under*-read — that fits roll's remarkably constant 0.62 but not pitch's excess;
|
||
- a **per-axis multiplier** applied to the cap that is not yet identified.
|
||
|
||
Recorded as an open question. The next measurement that would separate them is cheap:
|
||
**1 s versus 3 s bursts at the same throttle** — if the ramp is the cause, the longer
|
||
burst reads higher. Data:
|
||
[pitch](captures/pitch-burst-final.csv) · [roll](captures/roll-burst-final.csv).
|
||
|
||
## Note on the WEAK-pin guard
|
||
|
||
The roll run refused to start: after the pitch bursts the craft was no longer level,
|
||
so the row pin came back **WEAK** (margin 0.109). That refusal is correct for pitch
|
||
and yaw — but **roll is immune to the up/right labelling**, which is exactly what the
|
||
guard's own message says, so `ALLOW_WEAK_PIN=1` is the documented and legitimate
|
||
override there. It is worth having a guard that states its own exception.
|