re(challenge): the cleared-stage mask is CONFIRMED on the running game

Booted the title and read the two gate words live:

    0x828F40C0 = 0x00000002     word A
    0x828F4814 = 0x00000000     word B

Word A = 2 = bit 1. The profile's save is Stage 02 "At Standby" -- stage 01
cleared -- so the mask is exactly one bit, at the index of the one cleared
stage, 1-BASED. Reproduced across two cold boots. That confirms against a known
progress state, on the real game:

  - the singleton is the static object at 0x828F4070, as derived statically;
  - word A is a cleared-stage bitmask (not achievements, not a stage number);
  - bit index = stage id, 1-based, so TimeAttack's REQUIREMENT 16 means "clear
    stage 16" -- the last story mission;
  - word B is the challenge half and is 0 on a story-only profile.

New tools: gpoke.py (live guest-memory WRITE, companion to gmem.py, prints
before/after for every word), pad.py (drives the new --hid=file pad; replaces
vgamepad, which leaked to the host through /dev/uinput), challenge_probe.sh
(one blocking session: boot, wait for title, drive in, poke, screenshot).

Poking both words did NOT surface a challenge entry in EXTRAS -- and that menu
was built 26 s after the poke, so it is not staleness. Entering MISSION SELECT
then failed, but the log names the real cause and it is not the gate:
MmAllocatePhysicalMemoryEx could not satisfy a 128 MB request (parent free
30633/131072 pages), the guest threw a C++ exception, and Xenia surfaced its
generic "Disc Read Error". It is preceded by "BaseHeap::Release failed because
address is not a region start" -- a failed release leaking the range. Recorded
as an emulator heap problem, with the control run (same navigation, no poke)
named as the next step.
This commit is contained in:
2026-08-13 20:28:19 +00:00
parent f490fecefb
commit 023bb71cfd
7 changed files with 355 additions and 7 deletions

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Roll rate measured ABOUT THE FORWARD AXIS, so pitch cannot leak into it.
The previous attempt watched a non-forward row of the rotation matrix and got
numbers within a few per cent of the pitch run — because pitch moves that row as
much as roll does. The fix is to measure the rotation *in the plane perpendicular
to forward*: express the new up-vector in the OLD (up, right) basis and take
`atan2(u_new·w_old, u_new·u_old)`. Any component along forward — which is what
pitch produces — is dropped by construction.
Each phase is bracketed by HUD screenshots so the mission clock converts wall
seconds to game seconds within this run (the ratio has measured 1.260, 1.311 and
1.383 in three flights, so it cannot be assumed).
Usage: roll_axis.py <out.csv> [dwell_s]
"""
import json, math, os, struct, subprocess, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import speed_law
def pad(*a):
subprocess.run(["vgamepad", *a], capture_output=True)
def shot(n):
subprocess.run(["screenshot", f"/sylph-home/re/shots/{n}.png"], capture_output=True)
def rows_at(fd, off, cfg):
base = off + cfg["rot_delta"]
out = []
for r in range(3):
v = struct.unpack(">3f", os.pread(fd, 12, base + r * cfg["rot_stride"]))
n = math.sqrt(sum(c * c for c in v)) or 1.0
out.append(tuple(c / n for c in v))
return out
def dot(a, b):
return sum(a[i] * b[i] for i in range(3))
def main():
out_csv = sys.argv[1]
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 8.0
cfg = json.load(open("/tmp/nav-live.json"))
w, off, nm = speed_law.find_player()
if not w:
sys.exit("player entity not found after retries")
print(f"# locked on {nm}")
f_i = cfg.get("fwd_row", 0)
u_i, w_i = [r for r in (0, 1, 2) if r != f_i]
rows = []
for label, (trig, tv) in (("slow", ("LT", 1.0)), ("fast", ("RT", 1.0))):
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0"); pad("axis", "LX", "0.0")
pad("trig", trig, str(tv))
time.sleep(5.0)
shot(f"rollax_{label}_a")
pad("axis", "LX", "1.0")
t0, prev, swept = time.time(), rows_at(w.fd, off, cfg), 0.0
seq = []
while time.time() - t0 < dwell:
time.sleep(0.05)
cur = rows_at(w.fd, off, cfg)
# roll = rotation of `up` within the OLD (up, right) plane
d = math.degrees(math.atan2(dot(cur[u_i], prev[w_i]), dot(cur[u_i], prev[u_i])))
swept += abs(d)
seq.append((round(time.time() - t0, 3), round(d, 4)))
prev = cur
pad("axis", "LX", "0.0")
wall = seq[-1][0]
shot(f"rollax_{label}_b")
rows += [(label, *s) for s in seq]
print(f"# {label:<5} wall {wall:5.2f}s roll swept {swept:7.1f}deg "
f"rate {swept / wall:6.1f} deg/wall-s")
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0"); pad("reset")
with open(out_csv, "w") as f:
f.write("phase,t,droll_deg\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
print("# read HUD TIME off rollax_slow_a/b and rollax_fast_a/b for this run's clock ratio")
if __name__ == "__main__":
main()