Canary backs the whole guest address space with one shared-memory file, so the game's RAM is readable from the host while it runs -- no debugger, no emulator patch. That turns the parked Route-B question (fields the IDXD omits because they sit at their default) from "unreachable" into a table lookup. gmem.py maps guest VAs into /dev/shm/xenia_memory_* via Xenia's fixed table and searches only the allocated extents, so a full-RAM scan is ~0.2 s. weapon_runtime.py finds the parsed objects by scanning for their vtable, then *solves* the struct layout instead of guessing it: every (field, offset, encoding) triple is scored against the disc records, and a binding is accepted only with zero contradictions -- and marked confirmed only when >=10 records agree on >=3 distinct values, because a field whose samples are all one number matches any offset holding that constant. Result: Weapon (0xc0) and Shell (0x200) mapped, 4393 values the disc does not carry, for all 126 weapons at 5 % save progress. Cross-checks hold -- the Arsenal DATA SHEET read 4 for wep_05/wep_60 Max. Lock Ons and the memory read agrees; the in-flight HUD's 06000/00300 match LoadingCount; all 252 objects are byte-identical across a screen change, so this is definition data, not state. Two findings fell out: `IsCharging = Yes` switches LoadingCount and TriggerShotCount to float32 (it partitions the 8 float-encoded records exactly), and two .tbl entries declare Shell_TCAF_Ship_AAGun with conflicting Power -- the runtime keeps the one that omits it. Where the defaulted values *come from* (the IDXD's undecoded binary node region vs code defaults) is not settled here; they vary per weapon, so they are not one constructor constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
5.0 KiB
Python
163 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Read the *live* guest memory of a running Xenia Canary.
|
|
|
|
Canary backs the whole guest address space with a single shared-memory file,
|
|
`/dev/shm/xenia_memory_<pid-ish>`, so the guest's RAM is readable from the host
|
|
with no debugger, no emulator patch and no pause: open the file, seek, read.
|
|
|
|
The file is one flat image of Xenia's *physical* backing store; guest virtual
|
|
addresses map into it through Xenia's fixed table (memory.cc `map_info`).
|
|
`va_to_off()` implements that table, so callers work in guest VAs.
|
|
|
|
Sub-commands
|
|
find <pattern> search every allocated extent, print guest VAs
|
|
read <va> [len] hexdump guest memory
|
|
words <va> [n] dump n big-endian u32 / f32 pairs
|
|
|
|
`<pattern>` is a python literal-ish string: plain text, or `hex:0011aabb`.
|
|
Numbers accept 0x form. The scan uses SEEK_DATA so the ~4.6 GB of sparse holes
|
|
cost nothing.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import struct
|
|
|
|
# (guest_va_lo, guest_va_hi_inclusive, file_offset_of_lo) — Xenia memory.cc.
|
|
MAP = [
|
|
(0x00000000, 0x3FFFFFFF, 0x00000000),
|
|
(0x40000000, 0x7EFFFFFF, 0x40000000),
|
|
(0x7F000000, 0x7F0FFFFF, 0x00000000),
|
|
(0x7F100000, 0x7FFFFFFF, 0x00100000),
|
|
(0x80000000, 0x8FFFFFFF, 0x80000000),
|
|
(0x90000000, 0x9FFFFFFF, 0x80000000),
|
|
(0xA0000000, 0xBFFFFFFF, 0x100000000),
|
|
(0xC0000000, 0xDFFFFFFF, 0x100000000),
|
|
(0xE0000000, 0xFFFFFFFF, 0x100000000),
|
|
]
|
|
|
|
|
|
def va_to_off(va):
|
|
for lo, hi, base in MAP:
|
|
if lo <= va <= hi:
|
|
return base + (va - lo)
|
|
raise ValueError(f"va {va:#x} outside the guest map")
|
|
|
|
|
|
def off_to_vas(off):
|
|
"""All guest VAs that alias this file offset (the map is many-to-one)."""
|
|
out = []
|
|
for lo, hi, base in MAP:
|
|
if base <= off <= base + (hi - lo):
|
|
out.append(lo + (off - base))
|
|
return out
|
|
|
|
|
|
def primary_va(off):
|
|
"""The most useful VA for an offset: physical 0xA0000000+ / xex 0x80000000+."""
|
|
vas = off_to_vas(off)
|
|
return vas[0] if vas else None
|
|
|
|
|
|
def mem_path():
|
|
if len(sys.argv) > 1 and sys.argv[1].startswith("/dev/shm/"):
|
|
return sys.argv.pop(1)
|
|
cands = [f"/dev/shm/{n}" for n in os.listdir("/dev/shm") if n.startswith("xenia_memory_")]
|
|
if not cands:
|
|
sys.exit("no /dev/shm/xenia_memory_* — is Canary running?")
|
|
if len(cands) > 1:
|
|
sys.exit(f"several memory files, pass one explicitly: {cands}")
|
|
return cands[0]
|
|
|
|
|
|
def extents(fd, size):
|
|
"""Yield (start, end) of the file's allocated (non-hole) ranges."""
|
|
pos = 0
|
|
while pos < size:
|
|
try:
|
|
data = os.lseek(fd, pos, os.SEEK_DATA)
|
|
except OSError:
|
|
return
|
|
try:
|
|
hole = os.lseek(fd, data, os.SEEK_HOLE)
|
|
except OSError:
|
|
hole = size
|
|
yield (data, hole)
|
|
pos = hole
|
|
|
|
|
|
def parse_pattern(s):
|
|
if s.startswith("hex:"):
|
|
return bytes.fromhex(s[4:])
|
|
return s.encode("latin-1")
|
|
|
|
|
|
def cmd_find(f, size, args):
|
|
pat = parse_pattern(args[0])
|
|
limit = int(args[1]) if len(args) > 1 else 64
|
|
hits = 0
|
|
CHUNK = 1 << 24
|
|
for start, end in extents(f.fileno(), size):
|
|
pos = start
|
|
carry = b""
|
|
carry_at = start
|
|
while pos < end:
|
|
f.seek(pos)
|
|
buf = f.read(min(CHUNK, end - pos))
|
|
if not buf:
|
|
break
|
|
blob = carry + buf
|
|
base = carry_at
|
|
for m in re.finditer(re.escape(pat), blob):
|
|
off = base + m.start()
|
|
va = primary_va(off)
|
|
print(f"{off:#013x} va {va:#010x}" if va is not None else f"{off:#013x} va ?")
|
|
hits += 1
|
|
if hits >= limit:
|
|
return
|
|
keep = len(pat) - 1
|
|
carry = blob[-keep:] if keep else b""
|
|
carry_at = base + len(blob) - len(carry)
|
|
pos += len(buf)
|
|
if hits == 0:
|
|
print("(no hits)", file=sys.stderr)
|
|
|
|
|
|
def cmd_read(f, size, args):
|
|
va = int(args[0], 0)
|
|
n = int(args[1], 0) if len(args) > 1 else 256
|
|
f.seek(va_to_off(va))
|
|
data = f.read(n)
|
|
for i in range(0, len(data), 16):
|
|
row = data[i : i + 16]
|
|
hexs = " ".join(f"{b:02x}" for b in row)
|
|
txt = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
|
|
print(f"{va + i:08x} {hexs:<47} |{txt}|")
|
|
|
|
|
|
def cmd_words(f, size, args):
|
|
va = int(args[0], 0)
|
|
n = int(args[1], 0) if len(args) > 1 else 32
|
|
f.seek(va_to_off(va))
|
|
data = f.read(n * 4)
|
|
for i in range(0, len(data) - 3, 4):
|
|
(u,) = struct.unpack_from(">I", data, i)
|
|
(fl,) = struct.unpack_from(">f", data, i)
|
|
fs = f"{fl:.6g}" if -1e30 < fl < 1e30 else ""
|
|
print(f"{va + i:08x} +{i:04x} {u:#010x} {u:>12} {fs}")
|
|
|
|
|
|
def main():
|
|
path = mem_path()
|
|
if len(sys.argv) < 2:
|
|
sys.exit(__doc__)
|
|
cmd, args = sys.argv[1], sys.argv[2:]
|
|
size = os.path.getsize(path)
|
|
with open(path, "rb") as f:
|
|
{"find": cmd_find, "read": cmd_read, "words": cmd_words}[cmd](f, size, args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|