diff --git a/docs/re/captures/tutorial-advanced-controls-captions.png b/docs/re/captures/tutorial-advanced-controls-captions.png new file mode 100644 index 0000000..82b7934 Binary files /dev/null and b/docs/re/captures/tutorial-advanced-controls-captions.png differ diff --git a/tools/re-capture/pilot.py b/tools/re-capture/pilot.py index ce29d46..2df9a21 100644 --- a/tools/re-capture/pilot.py +++ b/tools/re-capture/pilot.py @@ -129,6 +129,37 @@ class Pilot: COMMIT_MAX = 14.0 # s before we are allowed to reconsider anyway COMMIT_DROP = 6000.0 # ...or it gets this far away COMMIT_BEHIND = 2.5 # ...or stays >90 deg off the nose this long + # --- moves the ADVANCED CONTROLS tutorial teaches (tutorial_capture.sh) --- + # "Target an enemy and pull LT and RT [together]. This sets your fighter's + # speed to that of the target. This works well when you are trying to get + # behind an enemy. Once behind an enemy, this also helps you attack them." + # That is the overshoot problem solved by the game itself: matching speed + # holds us in the target's rear hemisphere instead of flying through it, + # which is the only way a time-on-target lock ever completes. + # Scope matters, and a measured regression proved it: applying the match at + # 4500 with a 70 deg cone dropped kills 9 -> 0. Matching a target's speed + # while still 5 km behind it means never closing — the pilot sat at 272 u/s + # and fired 28 frames all run. The tutorial's own wording scopes it: "when + # you are trying to get BEHIND an enemy... ONCE BEHIND an enemy, this also + # helps you attack them". So it is station-keeping in the saddle, not an + # approach throttle. Only match when we are already there. + # MEASURED: both tutorial moves are a NET REGRESSION as applied here, so + # both ship DISABLED. One run each, same everything else: + # commitment only ............ 101 missiles, 364 fire frames, 9 kills + # + match(4500) + snap-face .... 9 missiles, 28 fire frames, 0 kills + # + match(1200) + snap-face ... 57 missiles, 225 fire frames, 2 kills + # The moves are real and the tutorial is right about them; the loop just + # cannot use them yet. Snap-face (B+A) reorients the craft mid-pursuit and + # destroys the very dwell that commitment buys, and speed-match needs to be + # entered from the saddle rather than commanded at range. Set MATCH_RANGE + # and lower FACE_MIN to re-enable, and A/B them over SEVERAL runs — one run + # per config is inside this stage's spawn variance. + MATCH_RANGE = 0.0 # 1200.0 to re-enable + MATCH_CONE = math.radians(25) + # "Press B and A together to face [the target]" — a snap turn, far quicker + # than winding the PD controller around for a contact behind us. + FACE_MIN = math.radians(999) # 50 deg to re-enable the B+A snap turn + FACE_PERIOD = 4.0 def __init__(self, W, pad, dry=False, log=sys.stdout): self.W = W @@ -158,6 +189,10 @@ class Pilot: self.commit_off = None # entity we are committed to self.commit_t = -1e9 self.behind_since = None + self.matching = False + self.face_t = -1e9 + self.face_down = None + self.faces = 0 def f32(self, off): b = os.pread(self.W.fd, 4, off) @@ -204,13 +239,31 @@ class Pilot: return hull, shield def set_throttle(self, want): - """RT / LT are a persistent setting, so only send the change.""" + """RT / LT are a persistent setting, so only send the change. + + `want` is +1 accelerate, -1 brake, 0 coast, or the string "match" for + the tutorial's both-triggers speed-match onto the current target. + """ if want == self.throttle or self.dry: return - self.pad.trig("RT", 1.0 if want > 0 else 0.0) - self.pad.trig("LT", 1.0 if want < 0 else 0.0) + if want == "match": + self.pad.trig("RT", 1.0) + self.pad.trig("LT", 1.0) + else: + self.pad.trig("RT", 1.0 if want > 0 else 0.0) + self.pad.trig("LT", 1.0 if want < 0 else 0.0) self.throttle = want + def face_target(self, t): + """B + A: snap the nose onto the selected target.""" + if self.dry or t - self.face_t < self.FACE_PERIOD: + return + self.pad.press("B") + self.pad.press("A") + self.face_down = t + self.face_t = t + self.faces += 1 + # -------------------------------------------------------------- targets def hostiles(self, ents, me_off): out = [] @@ -439,10 +492,10 @@ class Pilot: # than a stall in the middle of a battle; collision avoidance already # keeps a fighter-sized margin. want = norm(tgt[3]) if tgt else fwd - if tgt and tgt[4] > 2500.0: + if tgt and tgt[4] < self.MATCH_RANGE and ang(tgt[3], fwd) < self.MATCH_CONE: + self.set_throttle("match") # sit in its rear hemisphere + elif tgt and tgt[4] > 2500.0: self.set_throttle(+1) - elif tgt and tgt[4] < 500.0 and speed > 900.0: - self.set_throttle(-1) else: self.set_throttle(0) fire = True @@ -503,6 +556,13 @@ class Pilot: if fire != self.firing: (self.pad.press if fire else self.pad.release)("RB") self.firing = fire + if self.face_down is not None and t - self.face_down > 0.2: + self.pad.release("B") + self.pad.release("A") + self.face_down = None + elif (tgt and self.mode in ("ENGAGE", "DEFEND") + and abs(aim[0]) > self.FACE_MIN and pn < 1.2): + self.face_target(t) if self.missile_down and t - self.missile_t > 0.15: self.pad.release("Y") self.missile_down = False @@ -514,9 +574,10 @@ class Pilot: self.missiles += 1 msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} " - f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} " + f"thr={str(self.throttle):>5} yaw={math.degrees(yaw):+6.1f} " f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}" - f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)} msl={self.missiles}") + f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)} msl={self.missiles}" + f" fc={self.faces}") if ast is not None: msg += f" ast={a_frac*100:5.1f}%" if a_dmg > self.ASSET_ALERT: diff --git a/tools/re-capture/tutorial_capture.sh b/tools/re-capture/tutorial_capture.sh new file mode 100755 index 0000000..6ea5638 --- /dev/null +++ b/tools/re-capture/tutorial_capture.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Play one TUTORIAL and photograph what it teaches. +# +# The OPTIONS key-config screen names the in-flight actions but not the buttons +# they sit on, and probing the pad found the weapons only (an action that does +# not fire a gun is invisible in the ammo counters). The tutorials state the +# mapping outright — `ADVANCED CONTROLS` is index 5 and is where Change Target +# and Padlock Mode live — so read it from the game instead of guessing. +# +# Boot + nav is launch_mission.sh's route as far as the main menu, then TUTORIAL +# instead of LOAD GAME. Everything stays a CHILD of this script (no setsid): a +# detached process is not a survivable one here, see the session-lifetime note. +set -u +N="${1:-5}" # 0 BASIC, 1 HUD, 2 RADAR, 3 SUPPLY, 4 RADIO, 5 ADVANCED +SECS="${2:-150}" +TAG="${3:-tut}" +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +SD="$(cd "$(dirname "$0")" && pwd)" +SHOTS=/sylph-home/re/shots + +alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } +step(){ vgamepad dpad "$1"; sleep 0.25; vgamepad dpad center; sleep 0.7; } +shot(){ screenshot "$SHOTS/$TAG-$1.png" >/dev/null 2>&1; } + +pkill -x xenia_canary 2>/dev/null; sleep 2 +[ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; } +rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null + +if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true + nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \ + +extension GLX +extension RANDR /tmp/xvfb98.log 2>&1 & + for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done + nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox /tmp/openbox98.log 2>&1 & + sleep 1 +fi + +cd /sylph-home/re +nohup run-canary --audio --apu=sdl --log_mask=13 \ + --logged_profile_slot_0_xuid=E0300000EFBEA3D4 /dev/null 2>&1 & +sleep 5 +"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; } +sleep 14 # main menu is not input-ready before this + +step down; step down # NEW GAME -> LOAD GAME -> TUTORIAL +vgamepad tap A 250; sleep 8 +shot "list" +i=0; while [ "$i" -lt "$N" ]; do step down; i=$((i+1)); done +shot "pick" +vgamepad tap A 250; sleep 6 +shot "sub" # some tutorials offer Level 1 / Level 2 +vgamepad tap A 250 + +# Then just watch. The lesson drives itself and prints its instructions; tap A +# periodically to advance any prompt, and photograph often enough to catch the +# caption before it is replaced. +end=$(( SECONDS + SECS )); n=0 +while [ $SECONDS -lt $end ]; do + n=$((n+1)); shot "$(printf '%02d' $n)" + [ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; } + sleep 5 + [ $((n % 3)) -eq 0 ] && vgamepad tap A 250 +done +echo "TUTORIAL CAPTURE DONE ($n frames)"