Created
March 13, 2026 14:41
-
-
Save akleemans/0101b3d3cbd030a18c1016963a070df5 to your computer and use it in GitHub Desktop.
GameBoy map extraction script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [ | |
| # "pillow", | |
| # "pyboy", | |
| # ] | |
| # /// | |
| from __future__ import annotations | |
| import sys | |
| import threading | |
| import queue | |
| from dataclasses import dataclass | |
| from PIL import Image | |
| from pyboy import PyBoy | |
| SCREEN_W, SCREEN_H = 160, 144 | |
| BG_MAP_PX = 256 # SCX/SCY wrap size (pixels) | |
| HUD_H = 16 # bottom HUD height to remove | |
| def start_stdin_listener(cmd_q: "queue.Queue[str]") -> None: | |
| def run(): | |
| while True: | |
| line = sys.stdin.readline() | |
| if not line: | |
| break | |
| cmd = line.strip().lower() | |
| if cmd: | |
| cmd_q.put(cmd[:1]) | |
| threading.Thread(target=run, daemon=True).start() | |
| @dataclass | |
| class GrowingCanvas: | |
| img: Image.Image | |
| origin_x: int | |
| origin_y: int | |
| @staticmethod | |
| def new(initial_w: int = 2048, initial_h: int = 2048): | |
| bg = (220, 220, 220) | |
| return GrowingCanvas( | |
| img=Image.new("RGB", (initial_w, initial_h), bg), | |
| origin_x=initial_w // 2, | |
| origin_y=initial_h // 2, | |
| ) | |
| def ensure_visible(self, x, y, w, h, pad=256): | |
| px0 = x + self.origin_x | |
| py0 = y + self.origin_y | |
| px1 = px0 + w | |
| py1 = py0 + h | |
| left_need = max(0, -px0 + pad) | |
| top_need = max(0, -py0 + pad) | |
| right_need = max(0, px1 - self.img.width + pad) | |
| bot_need = max(0, py1 - self.img.height + pad) | |
| if left_need or top_need or right_need or bot_need: | |
| bg = (220, 220, 220) | |
| new_w = self.img.width + left_need + right_need | |
| new_h = self.img.height + top_need + bot_need | |
| new_img = Image.new("RGB", (new_w, new_h), bg) | |
| new_img.paste(self.img, (left_need, top_need)) | |
| self.img = new_img | |
| self.origin_x += left_need | |
| self.origin_y += top_need | |
| def paste(self, patch, x, y): | |
| self.ensure_visible(x, y, patch.width, patch.height) | |
| self.img.paste(patch, (x + self.origin_x, y + self.origin_y)) | |
| def signed_mod_delta(new, old, mod): | |
| d = (new - old) % mod | |
| if d > mod // 2: | |
| d -= mod | |
| return d | |
| def render_screen_patch(pyboy: PyBoy) -> Image.Image: | |
| img = pyboy.screen.image.convert("RGB") | |
| return img.crop((0, 0, SCREEN_W, SCREEN_H - HUD_H)) | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print("Usage: python gb_auto_mapper.py game.gb [output.png]") | |
| return 2 | |
| rom = sys.argv[1] | |
| out = sys.argv[2] if len(sys.argv) >= 3 else "map.png" | |
| pyboy = PyBoy(rom, window_type="SDL2") | |
| canvas = GrowingCanvas.new() | |
| cmd_q: "queue.Queue[str]" = queue.Queue() | |
| start_stdin_listener(cmd_q) | |
| recording = False | |
| prev_scx = prev_scy = None | |
| world_x = world_y = 0 | |
| print("Commands in terminal:") | |
| print(" r + Enter -> toggle recording") | |
| print(" s + Enter -> save PNG") | |
| print(" c + Enter -> clear canvas") | |
| print(" q + Enter -> quit") | |
| print() | |
| print(f"HUD crop: removing bottom {HUD_H}px (set HUD_H to tweak)") | |
| try: | |
| while pyboy.tick(): | |
| # Handle commands (non-blocking) | |
| while True: | |
| try: | |
| cmd = cmd_q.get_nowait() | |
| except queue.Empty: | |
| break | |
| if cmd == "r": | |
| recording = not recording | |
| print("Recording:", recording) | |
| (prev_scx, prev_scy), _ = pyboy.screen.get_tilemap_position() | |
| world_x = world_y = 0 | |
| elif cmd == "s": | |
| canvas.img.save(out) | |
| print("Saved:", out) | |
| elif cmd == "c": | |
| canvas = GrowingCanvas.new() | |
| print("Canvas cleared") | |
| elif cmd == "q": | |
| print("Quit requested") | |
| return 0 | |
| if not recording: | |
| continue | |
| (scx, scy), _ = pyboy.screen.get_tilemap_position() | |
| if prev_scx is None: | |
| prev_scx, prev_scy = scx, scy | |
| dx = signed_mod_delta(scx, prev_scx, BG_MAP_PX) | |
| dy = signed_mod_delta(scy, prev_scy, BG_MAP_PX) | |
| prev_scx, prev_scy = scx, scy | |
| world_x += dx | |
| world_y += dy | |
| patch = render_screen_patch(pyboy) | |
| canvas.paste(patch, world_x, world_y) | |
| finally: | |
| pyboy.stop() | |
| canvas.img.save(out) | |
| print("Final map saved:", out) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment