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.
661 lines
33 KiB
Markdown
661 lines
33 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).
|