Skip to content

Instantly share code, notes, and snippets.

@akleemans
Created March 13, 2026 14:41
Show Gist options
  • Select an option

  • Save akleemans/0101b3d3cbd030a18c1016963a070df5 to your computer and use it in GitHub Desktop.

Select an option

Save akleemans/0101b3d3cbd030a18c1016963a070df5 to your computer and use it in GitHub Desktop.
GameBoy map extraction script
# /// 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