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.
71 lines
2.1 KiB
Python
Executable File
71 lines
2.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Drive Xenia Canary's `--hid=file` pad — the container-safe controller.
|
|
|
|
Replaces the old `vgamepad` path, which created its device through `/dev/uinput`.
|
|
Input devices are not namespaced, so that device registered with the HOST's input
|
|
stack and every scripted press leaked to the user's desktop. This one writes a
|
|
text file the emulator polls; nothing leaves the container.
|
|
|
|
pad.py set "press=A" set the pad state and leave it held
|
|
pad.py clear release everything
|
|
pad.py tap A [secs] press, hold `secs` (default 0.10), release
|
|
pad.py dpad down [secs] one menu step (default 0.06 — longer
|
|
auto-repeats and overshoots)
|
|
pad.py hold "lt=255" secs hold an arbitrary state for `secs`
|
|
|
|
Buttons: UP DOWN LEFT RIGHT START BACK LS RS LB RB A B X Y.
|
|
Path from $XENIA_PAD_FILE, default /tmp/xenia_pad.txt (matches --pad_file).
|
|
|
|
Menu conventions in this game: A = OK, B = Back, Y = Gallery/extra, X = Delete.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
PAD = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt")
|
|
|
|
|
|
def write(state: str):
|
|
# The driver re-parses on any (mtime-ns, size) change, so a plain rewrite is
|
|
# enough — but write through a temp + rename so a poll can never observe a
|
|
# half-written file.
|
|
tmp = PAD + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.write(state)
|
|
os.replace(tmp, PAD)
|
|
|
|
|
|
def main():
|
|
a = sys.argv[1:]
|
|
if not a:
|
|
print(__doc__)
|
|
return 1
|
|
cmd = a[0]
|
|
if cmd == "set":
|
|
write(a[1])
|
|
elif cmd == "clear":
|
|
write("")
|
|
elif cmd == "tap":
|
|
secs = float(a[2]) if len(a) > 2 else 0.10
|
|
write(f"press={a[1]}")
|
|
time.sleep(secs)
|
|
write("")
|
|
elif cmd == "dpad":
|
|
secs = float(a[2]) if len(a) > 2 else 0.06
|
|
write(f"press={a[1].upper()}")
|
|
time.sleep(secs)
|
|
write("")
|
|
elif cmd == "hold":
|
|
write(a[1])
|
|
time.sleep(float(a[2]))
|
|
write("")
|
|
else:
|
|
print(f"unknown command {cmd!r}")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|