diff options
| author | gbc dev <gbc@localhost> | 2026-07-15 01:32:57 +0200 |
|---|---|---|
| committer | gbc dev <gbc@localhost> | 2026-07-15 01:32:57 +0200 |
| commit | f36200458ef8d4c698c41c3543b51f3235a25d19 (patch) | |
| tree | f23cb54f6aa471eacb02e7759982a9081f5ed048 /tools | |
| parent | apu: register-level emulation of 0xFF10-0xFF3F (fixes music fades) (diff) | |
| download | sl0pboy-f36200458ef8d4c698c41c3543b51f3235a25d19.tar.gz sl0pboy-f36200458ef8d4c698c41c3543b51f3235a25d19.tar.xz sl0pboy-f36200458ef8d4c698c41c3543b51f3235a25d19.zip | |
tools: live debug HUD (gbhud.py) + 'gbctl hud'
Reads WRAM over the control socket, resolves addresses from pokeyellow.sym, and
pretty-prints game state (location, player pos/facing, movement+collision debug,
player name/money/badges, party nicknames/levels/HP bars) refreshing ~5Hz.
'gbctl hud' spawns it in a tmux split bound to the live instance.
Diffstat (limited to 'tools')
| -rwxr-xr-x | tools/gbhud.py | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/tools/gbhud.py b/tools/gbhud.py new file mode 100755 index 0000000..f9f2c68 --- /dev/null +++ b/tools/gbhud.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""gbhud.py [--sock PATH] [--sym PATH] — live debug HUD for the gbc emulator +running Pokemon Yellow. Reads WRAM over the control socket, resolves symbols +from pokeyellow.sym, parses game state, and pretty-prints it, refreshing ~5Hz. + +Meant to run in its own tmux pane (see `gbctl hud`).""" +import socket, sys, os, time, glob, re + +REGDIR = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "gbctl") +SYM_DEFAULT = os.path.expanduser("~/dev/pokeyellow/pokeyellow.sym") +MAPCONST = os.path.expanduser("~/dev/pokeyellow/constants/map_constants.asm") + +# ---- symbol + map-name tables ---- +def load_syms(path): + syms = {} + try: + for line in open(path): + m = re.match(r"\s*[0-9A-Fa-f]+:([0-9A-Fa-f]+)\s+(\S+)", line) + if m: + syms[m.group(2)] = int(m.group(1), 16) + except OSError: + pass + return syms + +def load_maps(path): + maps, idx = {}, 0 + try: + for line in open(path): + m = re.match(r"\s*map_const\s+(\w+)", line) + if m: + maps[idx] = m.group(1).replace("_", " ").title() + idx += 1 + except OSError: + pass + return maps + +def gb_decode(bs): + o = "" + for b in bs: + if b == 0x50: break + if 0x80 <= b <= 0x99: o += chr(ord('A') + b - 0x80) + elif 0xA0 <= b <= 0xB9: o += chr(ord('a') + b - 0xA0) + elif 0xF6 <= b <= 0xFF: o += "0123456789"[b - 0xF6] + elif b == 0x7F: o += " " + else: o += "?" + return o + +# ---- socket ---- +def resolve_sock(arg): + if arg: return arg + if os.environ.get("GBCTL_SOCK"): return os.environ["GBCTL_SOCK"] + live = [] + for p in glob.glob(os.path.join(REGDIR, "*.json")): + import json + try: + d = json.load(open(p)) + if os.path.exists(d.get("sock", "")): live.append(d["sock"]) + except Exception: pass + return live[0] if len(live) == 1 else None + +class Mem: + """Batched WRAM reader over the control socket.""" + def __init__(self, sock): + self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.s.settimeout(2); self.s.connect(sock) + self._line() # greeting + self.blocks = {} + def _line(self): + b = b"" + while b"\n" not in b: b += self.s.recv(65536) + return b.split(b"\n", 1)[0].decode() + def read(self, addr, n): + self.s.sendall(f"read 0x{addr:04X} {n}\n".encode()) + h = self._line().split()[-1] + return bytes(int(h[i*2:i*2+2], 16) for i in range(n)) + def refresh(self): + self.blocks = { + 0xC100: self.read(0xC100, 0x20), + 0xCD48: self.read(0xCD48, 0x90), + 0xD158: self.read(0xD158, 0x1A0), + 0xD340: self.read(0xD340, 0x60), + 0xD520: self.read(0xD520, 0x20), + 0xD720: self.read(0xD720, 0x20), + } + def b(self, addr): + for base, blk in self.blocks.items(): + if base <= addr < base + len(blk): return blk[addr - base] + return 0 + def w(self, addr): # little-endian word + return self.b(addr) | (self.b(addr+1) << 8) + def wbe(self, addr): # big-endian word + return (self.b(addr) << 8) | self.b(addr+1) + +# ---- rendering ---- +C = lambda n: f"\x1b[{n}m" +RESET, DIM, BOLD = C(0), C(2), C(1) +CY, GR, YE, RD, MG, WH = C(96), C(92), C(93), C(91), C(95), C(97) +FACING = {0x00: "DOWN", 0x04: "UP", 0x08: "LEFT", 0x0C: "RIGHT"} +BADGES = ["BOULDER","CASCADE","THUNDER","RAINBOW","SOUL","MARSH","VOLCANO","EARTH"] + +def bar(cur, mx, width=10): + if mx <= 0: return " " * width + f = max(0, min(width, round(cur / mx * width))) + col = GR if cur > mx*0.5 else (YE if cur > mx*0.2 else RD) + return col + "█"*f + DIM + "░"*(width-f) + RESET + +ANSI = re.compile(r"\x1b\[[0-9;]*m") +def vlen(x): return len(ANSI.sub("", x)) + +W = 46 +def render(m, S, MAPS): + def s(name): return S.get(name, 0) + out = [] + def row(txt): + pad = (W - 3) - vlen(txt) + out.append(f"{CY}│{RESET} " + txt + " "*max(0, pad) + f"{CY}│{RESET}") + def hr(title=""): + if title: + out.append(f"{CY}├─ {BOLD}{title}{RESET}{CY} " + "─"*(W-5-len(title)) + "┤"+RESET) + else: + out.append(CY+"├"+"─"*(W-2)+"┤"+RESET) + out.append(CY+"┌"+"─"*(W-2)+"┐"+RESET) + row(f"{BOLD}{MG}SL0P DEBUG HUD{RESET} {DIM}pokemon yellow{RESET}") + + mapid = m.b(s("wCurMap")) + x, y = m.b(s("wXCoord")), m.b(s("wYCoord")) + facing = FACING.get(m.b(s("wSpritePlayerStateData1FacingDirection")), "?") + hr("LOCATION") + row(f"{WH}{MAPS.get(mapid,'?')}{RESET} {DIM}(0x{mapid:02X}){RESET} tileset {m.b(s('wCurMapTileset'))}") + row(f"pos {YE}x={x:<2} y={y:<2}{RESET} facing {YE}{facing}{RESET}") + + hr("MOVEMENT / COLLISION") + walk = m.b(s("wWalkCounter")); joyig = m.b(s("wJoyIgnore")) + front = m.b(s("wTileInFrontOfPlayer")); standing = m.b(s("wTilePlayerStandingOn")) + ctl = GR+"YES"+RESET if joyig == 0 and walk == 0 else RD+"NO"+RESET + row(f"walkCounter={walk} joyIgnore=0x{joyig:02X} canMove={ctl}") + row(f"tile-standing={CY}0x{standing:02X}{RESET} tile-front={CY}0x{front:02X}{RESET}") + + hr("PLAYER") + name = gb_decode(m.blocks[0xD158][0:11]) + money = "".join(f"{m.b(0xD346+i):02X}" for i in range(3)) + badges = m.b(s("wObtainedBadges")) + bset = [BADGES[i] for i in range(8) if badges & (1<<i)] + row(f"{WH}{name}{RESET} {DIM}id 0x{m.wbe(s('wPlayerID')):04X}{RESET} {YE}\u00a5{money}{RESET}") + row(f"badges {len(bset)}/8 {DIM}{','.join(bset) or '-'}{RESET}") + + cnt = m.b(s("wPartyCount")) + hr(f"PARTY ({cnt})") + mon1 = s("wPartyMon1"); nicks = s("wPartyMonNicks") + for i in range(min(cnt, 6)): + base = mon1 + i*44 + lvl = m.b(base+0x21); hp = m.wbe(base+1); mhp = m.wbe(base+0x22) + nick = gb_decode(m.blocks[0xD158][nicks-0xD158 + i*11 : nicks-0xD158 + i*11 + 11]) + row(f"{WH}{nick:<10}{RESET} Lv{lvl:>3} {bar(hp,mhp)} {hp:>3}/{mhp:<3}") + out.append(CY+"└"+"─"*(W-2)+"┘"+RESET) + return "\n".join(out) + +def main(): + argv = sys.argv[1:] + sock = None; sym = SYM_DEFAULT + if "--sock" in argv: sock = argv[argv.index("--sock")+1] + if "--sym" in argv: sym = argv[argv.index("--sym")+1] + sock = resolve_sock(sock) + if not sock: print("no live emulator (run gbctl spawn)"); return + S = load_syms(sym); MAPS = load_maps(MAPCONST) + sys.stdout.write("\x1b[?25l") # hide cursor + try: + m = None + while True: + try: + if m is None: m = Mem(sock) + m.refresh() + sys.stdout.write("\x1b[H\x1b[2J" + render(m, S, MAPS) + "\n") + sys.stdout.flush() + except (OSError, ConnectionError): + m = None + sys.stdout.write("\x1b[H\x1b[2J waiting for emulator...\n"); sys.stdout.flush() + time.sleep(0.2) + except KeyboardInterrupt: + pass + finally: + sys.stdout.write("\x1b[?25h\n") + +if __name__ == "__main__": + main() |
