aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAsh Ketchum <no-reply@sl0p.foo>2026-07-15 12:05:18 +0200
committerAsh Ketchum <no-reply@sl0p.foo>2026-07-15 12:05:18 +0200
commit3cd46213621e07632c41285bba2a4de34a1dceac (patch)
tree6a9f19143490e262616e228b0dc9465432cf3c11
parentexplore: fix map rendering (compute the view pointer ourselves) (diff)
downloadpokeyellow-3cd46213621e07632c41285bba2a4de34a1dceac.tar.gz
pokeyellow-3cd46213621e07632c41285bba2a4de34a1dceac.tar.xz
pokeyellow-3cd46213621e07632c41285bba2a4de34a1dceac.zip
tools: render_overworld.py - build seamless Kanto map from source
Ground-truth overworld generator, no emulator. Parses map_const dimensions, the per-map headers (tileset + connections with their alignment OFFSET), the .blk block data, .bst blocksets (4x4 tile indices) and .2bpp tilesets, renders every map full-size, and places them by the connection-offset math (BFS from Pallet): north:(ax+o,ay-Bh) south:(ax+o,ay+Ah) west:(ax-Bw,ay+o) east:(ax+Aw,ay+o) Produces a coherent 170x180-block (5440x5760px) Kanto overworld from 36 connected maps -- confirming connections join maps along shared edges with an offset, not as independent centered screens.
-rw-r--r--tools/render_overworld.py132
1 files changed, 132 insertions, 0 deletions
diff --git a/tools/render_overworld.py b/tools/render_overworld.py
new file mode 100644
index 00000000..cf4de255
--- /dev/null
+++ b/tools/render_overworld.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""render_overworld.py — build the seamless Kanto overworld straight from the
+PKY source data (no emulator). Ground truth for how maps connect.
+
+ maps/<Label>.blk raw W*H block indices
+ gfx/blocksets/<t>.bst 16 bytes/block = 4x4 tile indices
+ gfx/tilesets/<t>.2bpp 16 bytes/tile = 8x8 2bpp
+ data/maps/headers/*.asm tileset + connections (dir, target, offset)
+ constants/map_constants.asm map_const NAME, W, H (blocks)
+
+Placement (global block coords, top-left of each map), A --dir(offset o)--> B:
+ north: (ax+o, ay-Bh) south: (ax+o, ay+Ah)
+ west : (ax-Bw, ay+o) east : (ax+Aw, ay+o)
+"""
+import os, re, sys, glob
+from PIL import Image
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+def P(*a): return os.path.join(ROOT, *a)
+
+# --- map dimensions (blocks) -------------------------------------------------
+dims = {}
+for m in re.finditer(r'map_const\s+(\w+),\s*(\d+),\s*(\d+)', open(P('constants/map_constants.asm')).read()):
+ dims[m.group(1)] = (int(m.group(2)), int(m.group(3)))
+
+# --- tileset const order <-> tileset name ----------------------------------
+tconsts = re.findall(r'^\s*const\s+(\w+)', open(P('constants/tileset_constants.asm')).read(), re.M)
+tnames = re.findall(r'^\s*tileset\s+(\w+),', open(P('data/tilesets/tileset_headers.asm')).read(), re.M)
+const2tileset = dict(zip(tconsts, tnames)) # OVERWORLD -> Overworld
+
+# --- tileset name -> (bst path, 2bpp path) -----------------------------------
+tblk, tgfx = {}, {}
+pend_blk, pend_gfx = [], []
+for line in open(P('gfx/tilesets.asm')):
+ mb = re.match(r'\s*(\w+)_Block::(?:\s*INCBIN\s+"([^"]+)")?', line)
+ mg = re.match(r'\s*(\w+)_GFX::(?:\s*INCBIN\s+"([^"]+)")?', line)
+ if mg:
+ pend_gfx.append(mg.group(1))
+ if mg.group(2):
+ for n in pend_gfx: tgfx[n] = mg.group(2)
+ pend_gfx = []
+ elif mb:
+ pend_blk.append(mb.group(1))
+ if mb.group(2):
+ for n in pend_blk: tblk[n] = mb.group(2)
+ pend_blk = []
+
+# --- parse every map header: label, const, tileset, connections --------------
+maps = {} # const -> dict(label, tileset, conns=[(dir,tgtconst,off)])
+const2label = {}
+for f in glob.glob(P('data/maps/headers/*.asm')):
+ txt = open(f).read()
+ h = re.search(r'map_header\s+(\w+),\s*(\w+),\s*(\w+),', txt)
+ if not h: continue
+ label, const, tileset = h.group(1), h.group(2), h.group(3)
+ const2label[const] = label
+ conns = [(m.group(1), m.group(3), int(m.group(4)))
+ for m in re.finditer(r'connection\s+(\w+),\s*(\w+),\s*(\w+),\s*(-?\d+)', txt)]
+ maps[const] = dict(label=label, tileset=tileset, conns=conns)
+
+# --- blockset / tileset caches -----------------------------------------------
+SHADE = [(255,255,255),(168,168,168),(80,80,80),(0,0,0)]
+_bst, _gfx, _rendered = {}, {}, {}
+def blockset(name):
+ if name not in _bst: _bst[name] = open(P(tblk[name]),'rb').read()
+ return _bst[name]
+def tileset_gfx(name):
+ if name not in _gfx: _gfx[name] = open(P(tgfx[name]),'rb').read()
+ return _gfx[name]
+def tile_img(gfx, idx):
+ px = []
+ o = idx*16
+ for row in range(8):
+ b1, b2 = gfx[o+row*2], gfx[o+row*2+1]
+ for col in range(8):
+ v = ((b1>>(7-col))&1) | (((b2>>(7-col))&1)<<1)
+ px.append(SHADE[v])
+ im = Image.new('RGB',(8,8)); im.putdata(px); return im
+
+def render_map(const):
+ if const in _rendered: return _rendered[const]
+ label = maps[const]['label']; tname = const2tileset[maps[const]['tileset']]
+ w,h = dims[const]
+ blk = open(P('maps', label+'.blk'),'rb').read()
+ bs, gfx = blockset(tname), tileset_gfx(tname)
+ img = Image.new('RGB',(w*32,h*32),(0,0,0))
+ for br in range(h):
+ for bc in range(w):
+ if br*w+bc >= len(blk): continue
+ block = blk[br*w+bc]
+ for ty in range(4):
+ for tx in range(4):
+ ti = bs[block*16 + ty*4 + tx]
+ img.paste(tile_img(gfx,ti), (bc*32+tx*8, br*32+ty*8))
+ _rendered[const] = img
+ return img
+
+# --- BFS placement from PALLET_TOWN ------------------------------------------
+pos = {'PALLET_TOWN': (0,0)}
+q = ['PALLET_TOWN']
+while q:
+ a = q.pop(0)
+ if a not in maps: continue
+ ax, ay = pos[a]; aw, ah = dims[a]
+ for d, b, o in maps[a]['conns']:
+ if b in pos or b not in dims: continue
+ bw, bh = dims[b]
+ if d == 'north': pos[b] = (ax+o, ay-bh)
+ elif d == 'south': pos[b] = (ax+o, ay+ah)
+ elif d == 'west': pos[b] = (ax-bw, ay+o)
+ elif d == 'east': pos[b] = (ax+aw, ay+o)
+ else: continue
+ q.append(b)
+
+placed = [c for c in pos if c in maps and os.path.exists(P('maps', maps[c]['label']+'.blk'))]
+minx = min(pos[c][0] for c in placed); miny = min(pos[c][1] for c in placed)
+maxx = max(pos[c][0]+dims[c][0] for c in placed)
+maxy = max(pos[c][1]+dims[c][1] for c in placed)
+W, H = (maxx-minx)*32, (maxy-miny)*32
+print(f"placed {len(placed)} maps; world = {maxx-minx}x{maxy-miny} blocks = {W}x{H}px")
+
+canvas = Image.new('RGB',(W,H),(20,20,30))
+for c in sorted(placed, key=lambda c: (pos[c][1],pos[c][0])):
+ x = (pos[c][0]-minx)*32; y = (pos[c][1]-miny)*32
+ canvas.paste(render_map(c), (x,y))
+
+out = sys.argv[1] if len(sys.argv)>1 else '/tmp/kanto_overworld.png'
+canvas.save(out)
+# also a downscaled preview for quick viewing
+scale = max(1, max(W,H)//1600 + 1)
+canvas.resize((W//scale, H//scale)).save(out.replace('.png','_preview.png'))
+print(f"saved {out} ({W}x{H}) + preview /{scale}x")