aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorgbc dev <gbc@localhost>2026-07-15 01:32:57 +0200
committergbc dev <gbc@localhost>2026-07-15 01:32:57 +0200
commitf36200458ef8d4c698c41c3543b51f3235a25d19 (patch)
treef23cb54f6aa471eacb02e7759982a9081f5ed048 /tools
parentapu: register-level emulation of 0xFF10-0xFF3F (fixes music fades) (diff)
downloadsl0pboy-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-xtools/gbhud.py185
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()