From 1ddb699d12211ea29127a3060da6a779d31d9b88 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sun, 23 Aug 2026 22:39:33 +0000 Subject: [PATCH] tools+docs: the pitch stick sign was inverted - the pilot fires now Measured, not argued: command a 45-degree-off-the-nose error and watch whether it shrinks, both axes, both sides, two pulse widths, with the opposite sign as a control. Yaw's sign is correct (45 -> 11.3/32.7 at 0.6 s, 41.6/33.8 at 1.2 s). Pitch's is inverted - the pilot's own sign GREW the error every time (48.3/55.6/70.7/91.7) and the opposite shrank it every time (30.7/41.5/15.7/9.6). A method artefact is recorded because it gave the opposite answer first: a 3 s full-deflection pulse overshoots a 45-degree error so far that BOTH signs look wrong (45 -> 164 and 45 -> 178). A long pulse cannot answer a sign question. Verified against the game rather than by inspection. Before: fire=1 in 0 of 13521 samples, |aim yaw| pinned at 90.0, target 36-43 km away. After: 43 of 1732, aim down to 2.3 degrees, range median 6.3 km, and the HUD's own ammunition counters moving - NOSE BM 06000 -> 05723, MAIN MPM 00300 -> 00298. Also fixed a leftover of the same FIFO era: pilot.py called pad.f.write("tap A 90") for the target-select double tap, which raised AttributeError once Pad stopped having an `f`. Pad gained tap()/dpad(); ctrl_probe.py and target_probe.py still use pad.f and now say so in place. Still open: YOU KILLED is 0000 after 250 s of firing and REMAINING OB is still 012. The craft shoots, closes and selects; whether it destroys anything is next. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- docs/re/pilot-never-fires.md | 53 ++++++++++++++- tools/re-capture/ctrl_probe.py | 6 ++ tools/re-capture/flight_probe.py | 22 ++++++ tools/re-capture/pilot.py | 17 ++++- tools/re-capture/stick_sign.py | 112 +++++++++++++++++++++++++++++++ tools/re-capture/target_probe.py | 6 ++ 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100755 tools/re-capture/stick_sign.py diff --git a/docs/re/pilot-never-fires.md b/docs/re/pilot-never-fires.md index 97759304..d627c771 100644 --- a/docs/re/pilot-never-fires.md +++ b/docs/re/pilot-never-fires.md @@ -122,7 +122,58 @@ Same probe, on runs confirmed animating at both ends of every phase suspicion that the binding was wrong: `pos-0x70` is live and tracks the ship. It only looked dead because nothing was turning the ship. -## 🟡 Still open — the pilot does not converge +## ✅ AND THE SECOND HALF: the PITCH stick sign was inverted + +(The section below is what was open before this was measured; it is kept because +the sequence matters.) + +With the pad reaching the game, the sign question became testable and was +settled by commanding a **45°-off-the-nose error** and watching whether it +shrank — both axes, both sides, two pulse widths, plus the opposite sign as a +control: + +| pulse | axis | pilot's own sign | opposite sign | +|---|---|---|---| +| 0.6 s | yaw | 45° → **11.3** / **32.7** ✅ reduces | — | +| 1.2 s | yaw | 45° → **41.6** / **33.8** ✅ reduces | — | +| 0.6 s | pitch | 45° → 48.3 / 55.6 🔴 grows | 45° → **30.7** / **41.5** reduces | +| 1.2 s | pitch | 45° → 70.7 / 91.7 🔴 grows | 45° → **15.7** / **9.6** reduces | + +So `sticks()`'s `sy = -(KP*pitch - KD*…)` had the **wrong sign**; yaw's is right. + +🔴 **A method artefact worth recording, because it gave the opposite answer +first.** The first attempt used a **3 s** full-deflection pulse and reported +pitch growing under *both* signs (45° → 164° and 45° → 178°). That is +overshoot: three seconds of full pitch swings the ship far past a 45° error, so +the frozen world-direction ends up behind it whichever way it turns. Pulse +short, or measure the initial rotation direction — a long pulse cannot answer a +sign question. + +### Verified: the pilot fires + +Same mission, same binding, sign flipped: + +| | before | after | +|---|---|---| +| `fire=1` samples | **0** of 13 521 | **43** of 1 732 | +| `\|aim yaw\|` | median 90.0°, min 90.0° | min **2.3°**, 7 of 14 inside 25° in the first pass | +| range to target | median 43 km, min 36 km | median **6.3 km**, min 5.3 km | +| ammunition | untouched | `NOSE BM 06000 → 05723`, `MAIN MPM 00300 → 00298` | + +![the pilot firing](captures/stage02-pilot-firing-after-sign-fix.png) + +One more leftover of the same FIFO era was fixed on the way: `pilot.py` called +`self.pad.f.write("tap A 90")` for the target-select double tap, which raised +`AttributeError` the moment the class stopped having an `f`. `Pad` gained +`tap()`/`dpad()`; `ctrl_probe.py` and `target_probe.py` still use `pad.f` and now +say so in place. + +🟡 **Still open even so:** `YOU KILLED WARSHIPS/WARPLANES` is still `0000` after +250 s of firing, and `REMAINING OB` is still `012`. The craft shoots, closes and +selects; whether it actually *destroys* anything is the next thing to measure, +and it is what the objective-counter work is waiting on. + +## 🟡 The state before that measurement — the pilot does not converge With the pad fixed, a 150 s `pilot.py` run still fired **0** times: over its 86 targeted samples `|aim yaw|` is *still* exactly 90.0° and the range is diff --git a/tools/re-capture/ctrl_probe.py b/tools/re-capture/ctrl_probe.py index f3208bc7..7f7c29c6 100644 --- a/tools/re-capture/ctrl_probe.py +++ b/tools/re-capture/ctrl_probe.py @@ -27,6 +27,12 @@ import math import os import struct import sys + +# 🔴 DEAD FIFO HANDLE: this file uses `pad.f.write(...)`, the old vgamepad +# server protocol. flight_probe.Pad no longer has an `f` — it writes Canary's +# --hid=file pad file instead — so these calls now raise AttributeError. Use +# pad.tap()/pad.dpad(). Not converted here: neither tool has been re-run since. +# See docs/re/pilot-never-fires.md. import time import numpy as np diff --git a/tools/re-capture/flight_probe.py b/tools/re-capture/flight_probe.py index def40501..b8413d9c 100755 --- a/tools/re-capture/flight_probe.py +++ b/tools/re-capture/flight_probe.py @@ -99,6 +99,28 @@ class Pad: self.buttons.clear() self._write() + def tap(self, b, secs=0.09): + """Press and release, blocking for `secs`. + + The old FIFO server slept on the *server* side, so callers wrote + `tap A 90` and carried on; `pilot.py` still did that through `pad.f`, + which this class no longer has. Here the caller pays the 90 ms. That is + acceptable only because the one caller — the double-tap that selects a + target — is rate-limited; do not put this in a per-tick path. + """ + self.press(b) + time.sleep(secs) + self.release(b) + + def dpad(self, direction, secs=0.06): + """One d-pad step; `center` releases all four.""" + for d in ("UP", "DOWN", "LEFT", "RIGHT"): + self.buttons.discard(d) + if direction.lower() == "center": + self._write() + return + self.tap(direction.upper(), secs) + def vector(self): return [self.state[k] for k in ("LX", "LY", "RX", "RY", "LT", "RT")] diff --git a/tools/re-capture/pilot.py b/tools/re-capture/pilot.py index cba7aadb..980b08a1 100644 --- a/tools/re-capture/pilot.py +++ b/tools/re-capture/pilot.py @@ -283,8 +283,9 @@ class Pilot: """A, twice: make the GAME target what we are already pointing at.""" if self.dry or t - self.select_t < self.SELECT_PERIOD: return - self.pad.f.write("tap A 90\n") - self.pad.f.write("tap A 90\n") + # Target select is (A) pressed TWICE — see autopilot-memory-driven.md. + self.pad.tap("A", 0.09) + self.pad.tap("A", 0.09) self.select_t = t self.selects += 1 @@ -404,7 +405,17 @@ class Pilot: if ez < 0: # target behind: commit to a full turn yaw = math.copysign(math.pi / 2, ex if ex else 1.0) sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up)))) - sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right))))) + # 🔴 The leading minus was WRONG and is measured out (2026-08-23). With + # the pad finally reaching the game, a 45°-off-the-nose error was + # commanded with this convention and with its opposite, at 0.6 s and + # 1.2 s pulses, both sides: the pilot's own sign GREW the error every + # time (45° -> 48/56/71/92) and the opposite sign SHRANK it every time + # (45° -> 31/41/16/10). Yaw's sign, tested the same way, is correct. + # + # Method note, because the first attempt got the opposite answer: a 3 s + # full-deflection pulse OVERSHOOTS a 45° error so far that both signs + # look wrong. Pulse short, or measure the initial rotation direction. + sy = max(-1.0, min(1.0, self.KP * pitch - self.KD * float(np.dot(w, right)))) return sx, sy, yaw, pitch # ---------------------------------------------------------------- step diff --git a/tools/re-capture/stick_sign.py b/tools/re-capture/stick_sign.py new file mode 100755 index 00000000..e3ddbbad --- /dev/null +++ b/tools/re-capture/stick_sign.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Does `pilot.py`'s stick sign REDUCE its own aim error, or grow it? + +With the pad finally reaching the game (`pilot-never-fires.md`), the pilot still +fires 0 times in 150 s and `|aim yaw|` stays pinned at exactly 90° — the `ez < 0` +branch, i.e. the target is astern and stays astern. A controller whose stick sign +is inverted looks exactly like that: it turns away from the error it is nulling, +so the target never comes round the front. + +The test is direct and needs no target. Pick a direction 45° off the nose in the +ship's own frame, freeze it in WORLD coordinates, command the deflection +`sticks()` would command for that error, and see whether the error falls or +rises. Both signs are tested, because one of them agreeing by luck would prove +nothing. + +`sticks()`'s convention, replicated exactly: + + fwd = M[fwd_row] * fwd_sign + right = M[(fwd_row + 1) % 3] + up = cross(fwd, right) + yaw = atan2(dot(want, right), dot(want, fwd)) + sx = KP*yaw - KD*(body rate about up) + sy = -(KP*pitch - KD*(body rate about right)) + +The KD terms are dropped here: the P term saturates the stick anyway, and a +damping term cannot flip a sign. + +Usage: stick_sign.py [seconds_per_phase] +""" +import json +import math +import os +import sys +import time + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import frozen # noqa: E402 +import navigator # noqa: E402 +from flight_probe import Pad # noqa: E402 + + +def main(): + cfg = json.load(open(sys.argv[1])) + secs = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0 + W = navigator.World(cfg) + W.scan() + pad = Pad() + + def frame(): + ents = W.sample(time.time()) + me = next((e for e in ents if "Player" in e[1]), None) + if me is None: + return None + M = W.rot(me[0]) + if M is None: + return None + fwd = M[W.fwd_row] * W.fwd_sign + right = M[(W.fwd_row + 1) % 3] + return fwd, right, np.cross(fwd, right) + + def err(want, f): + fwd, right, up = f + yaw = math.atan2(float(np.dot(want, right)), float(np.dot(want, fwd))) + pitch = math.atan2(float(np.dot(want, up)), float(np.dot(want, fwd))) + return math.degrees(yaw), math.degrees(pitch) + + def trial(axis, side): + f0 = frame() + if f0 is None: + print(f"{axis}{side:+d}: no player/orientation") + return + fwd, right, up = f0 + base = right if axis == "yaw" else up + want = fwd * math.cos(math.radians(45)) + base * side * math.sin(math.radians(45)) + want = want / np.linalg.norm(want) + y0, p0 = err(want, f0) + # what sticks() would command for this error + if axis == "yaw": + pad.axis("LX", math.copysign(1.0, y0)) + cmd = f"LX={math.copysign(1.0, y0):+.0f}" + else: + pad.axis("LY", -math.copysign(1.0, p0)) + cmd = f"LY={-math.copysign(1.0, p0):+.0f}" + time.sleep(secs) + pad.reset() + f1 = frame() + froze = frozen.frozen(3.0)[0] + if f1 is None: + print(f"{axis}{side:+d}: lost the player") + return + y1, p1 = err(want, f1) + e0, e1 = (abs(y0), abs(y1)) if axis == "yaw" else (abs(p0), abs(p1)) + verdict = "REDUCES (sign correct)" if e1 < e0 - 1.0 else ( + "GROWS (sign INVERTED)" if e1 > e0 + 1.0 else "no change") + print(f"{axis} want{side:+d} {cmd}: |err| {e0:6.2f} -> {e1:6.2f} deg " + f"{verdict}{' *** FROZE - DISCARD ***' if froze else ''}") + + if frozen.frozen(3.0)[0]: + print("# GUEST IS ALREADY FROZEN — nothing to measure") + return 3 + for axis in ("yaw", "pitch"): + for side in (+1, -1): + trial(axis, side) + time.sleep(1.0) + pad.reset() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/target_probe.py b/tools/re-capture/target_probe.py index 45deaa01..da89db9b 100644 --- a/tools/re-capture/target_probe.py +++ b/tools/re-capture/target_probe.py @@ -28,6 +28,12 @@ import json import os import struct import sys + +# 🔴 DEAD FIFO HANDLE: this file uses `pad.f.write(...)`, the old vgamepad +# server protocol. flight_probe.Pad no longer has an `f` — it writes Canary's +# --hid=file pad file instead — so these calls now raise AttributeError. Use +# pad.tap()/pad.dpad(). Not converted here: neither tool has been re-run since. +# See docs/re/pilot-never-fires.md. import time import numpy as np