#!/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():
    # A snapshot (`cp --sparse=always /dev/shm/xenia_memory_* snap.bin`, ~2 s)
    # reads identically and does not contend with the running emulator, which
    # pegs every core under lavapipe. Point $GMEM_FILE at one to work offline.
    env = os.environ.get("GMEM_FILE")
    if env:
        return env
    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()
