#!/usr/bin/env python3 """gbhud.py [--sock PATH] [--sym PATH] — live debug HUD for the sl0pboy 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 _connectable(path): if not path or not os.path.exists(path): return False try: t = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) t.settimeout(0.3); t.connect(path); t.close() return True except OSError: return False def resolve_sock(pref=None): """Find a *live* control socket, test-connecting so stale sockets from a dead instance are skipped. `pref` (e.g. from --sock) is tried first, then the registry, then $GBCTL_SOCK - so the HUD follows respawns automatically.""" import json cands = [] if pref: cands.append(pref) for p in glob.glob(os.path.join(REGDIR, "*.json")): try: cands.append(json.load(open(p)).get("sock", "")) except Exception: pass if os.environ.get("GBCTL_SOCK"): cands.append(os.environ["GBCTL_SOCK"]) for c in cands: if _connectable(c): return c return 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), 0xFFB0: self.read(0xFFB0, 0x10), # HRAM: hJoyHeld @ 0xFFB4 } 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)) # hJoyHeld bit layout: A=0 B=1 SELECT=2 START=3 RIGHT=4 LEFT=5 UP=6 DOWN=7 def render_input(held): def lit(s, on): # bright-green + bold = a lit button; dim grey = idle return (f"\x1b[1;92m{s}\x1b[0m" if on else f"\x1b[2;90m{s}\x1b[0m") U, D = held>>6&1, held>>7&1 L, R = held>>5&1, held>>4&1 A, B = held&1, held>>1&1 SE, ST = held>>2&1, held>>3&1 return [ " " + lit("▲", U) + " " + lit("Ⓑ", B) + " " + lit("Ⓐ", A), lit("◄", L) + " " + lit("✜", 0) + " " + lit("►", R) + " " + lit("▁SEL▁", SE) + " " + lit("▁START▁", ST), " " + lit("▼", D), ] 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("INPUT") for ln in render_input(m.b(0xFFB4)): row(ln) 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] pref = sock 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: sk = resolve_sock(pref) # re-resolve every reconnect if not sk: sys.stdout.write("\x1b[H\x1b[2J waiting for emulator... " "(run: gbctl spawn )\n"); sys.stdout.flush() time.sleep(0.5); continue m = Mem(sk) m.refresh() sys.stdout.write("\x1b[H\x1b[2J" + render(m, S, MAPS) + "\n") sys.stdout.flush() except (OSError, ConnectionError, IndexError): m = None sys.stdout.write("\x1b[H\x1b[2J reconnecting...\n"); sys.stdout.flush() time.sleep(0.3) time.sleep(0.2) except KeyboardInterrupt: pass finally: sys.stdout.write("\x1b[?25h\n") if __name__ == "__main__": main()