From f36200458ef8d4c698c41c3543b51f3235a25d19 Mon Sep 17 00:00:00 2001 From: gbc dev Date: Wed, 15 Jul 2026 01:32:57 +0200 Subject: 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. --- core.3058597 | Bin 0 -> 11264000 bytes core.3061219 | Bin 0 -> 11268096 bytes core.3063669 | Bin 0 -> 11268096 bytes core.3068673 | Bin 0 -> 11268096 bytes core.3072453 | Bin 0 -> 11268096 bytes core.3073976 | Bin 0 -> 11268096 bytes core.3076382 | Bin 0 -> 11268096 bytes core.3080116 | Bin 0 -> 11268096 bytes core.3081720 | Bin 0 -> 11268096 bytes gbctl | 18 +++++- tools/gbhud.py | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 core.3058597 create mode 100644 core.3061219 create mode 100644 core.3063669 create mode 100644 core.3068673 create mode 100644 core.3072453 create mode 100644 core.3073976 create mode 100644 core.3076382 create mode 100644 core.3080116 create mode 100644 core.3081720 create mode 100755 tools/gbhud.py diff --git a/core.3058597 b/core.3058597 new file mode 100644 index 0000000..8e1fd6b Binary files /dev/null and b/core.3058597 differ diff --git a/core.3061219 b/core.3061219 new file mode 100644 index 0000000..9066b5d Binary files /dev/null and b/core.3061219 differ diff --git a/core.3063669 b/core.3063669 new file mode 100644 index 0000000..63cdb39 Binary files /dev/null and b/core.3063669 differ diff --git a/core.3068673 b/core.3068673 new file mode 100644 index 0000000..f652f89 Binary files /dev/null and b/core.3068673 differ diff --git a/core.3072453 b/core.3072453 new file mode 100644 index 0000000..e72709c Binary files /dev/null and b/core.3072453 differ diff --git a/core.3073976 b/core.3073976 new file mode 100644 index 0000000..04ca0da Binary files /dev/null and b/core.3073976 differ diff --git a/core.3076382 b/core.3076382 new file mode 100644 index 0000000..5f9535b Binary files /dev/null and b/core.3076382 differ diff --git a/core.3080116 b/core.3080116 new file mode 100644 index 0000000..a569f7e Binary files /dev/null and b/core.3080116 differ diff --git a/core.3081720 b/core.3081720 new file mode 100644 index 0000000..0699dd4 Binary files /dev/null and b/core.3081720 differ diff --git a/gbctl b/gbctl index 7eb521c..4c3d4f9 100755 --- a/gbctl +++ b/gbctl @@ -284,6 +284,22 @@ def cmd_screen(argv): r = subprocess.run(args, capture_output=True, text=True) sys.stdout.write(r.stdout) +def cmd_hud(argv): + # spawn a tmux pane running the live debug HUD, bound to the resolved socket + sock_path = _resolve_sock(argv) + if "TMUX" not in os.environ: + die("not inside tmux; gbctl hud needs a tmux session") + hud = os.path.join(HERE, "tools", "gbhud.py") + split = "-h" + if "--split" in argv: + i = argv.index("--split"); split = {"h":"-h","v":"-v"}.get(argv[i+1],"-h") + cmd = "exec python3 %s --sock %s" % (_shquote(hud), _shquote(sock_path)) + r = subprocess.run(["tmux", "split-window", split, "-P", "-F", "#{pane_id}", cmd], + capture_output=True, text=True) + if r.returncode != 0: + die("tmux spawn failed: %s" % r.stderr.strip()) + print("hud pane=%s sock=%s" % (r.stdout.strip(), sock_path)) + def cmd_monitor(argv): secs = float(argv[0]) if argv and _isnum(argv[0]) else None if secs is not None: @@ -319,7 +335,7 @@ def _isnum(x): # main dispatch # --------------------------------------------------------------------------- LIFECYCLE = {"spawn": cmd_spawn, "list": cmd_list, "stop": cmd_stop, - "screen": cmd_screen, "monitor": cmd_monitor} + "screen": cmd_screen, "monitor": cmd_monitor, "hud": cmd_hud} # convenience aliases -> raw protocol verbs ALIASES = {"press": None, "tap": None, "hold": None, "buttons": None} 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<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() -- cgit v1.3.1-sl0p