elya-snes / tools /render_frame.py
elyanlabs's picture
Source snapshot of github.com/Scottcjn/elya-snes@544ccea (part 4)
8e0f023 verified
Raw History Blame Contribute Delete
7.53 kB
#!/usr/bin/env python3
"""render_frame.py -- rebuild, on the host, exactly what the PPU would put on
the television, from the bytes the cartridge wrote into battery SRAM.
Screen capture is not available on this machine: the ares window is Wayland
native, `import -window root` gets nothing and grim fails because GNOME does
not implement wlr-screencopy. So the ROM reads its own OAM, VRAM and CGRAM
back out THROUGH THE PPU into SRAM, and this composites them.
That is not a workaround, it is a better instrument. A screenshot shows a
picture and you have to trust the emulator's renderer. This shows the object
table, the tilemaps, the palettes and the scroll registers that the picture
would be made from, and does the compositing in code that can be read.
Layer order is mode 1 with BG3's priority bit set in BGMODE, which puts the
text above everything: backdrop, BG2, BG1, objects, BG3.
"""
import os
import struct
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
A = os.path.join(ROOT, "assets")
W, H = 256, 224
SR_CGR = 0x1C00
SNAPS = {"act1": 0x3000, "act3": 0x4000}
OAM_LEN = 0x220
BG3_OFF = 0x220
SCROLL_OFF = 0xA20
STATE = ["logo", "play", "stop", "line", "ask", "answer", "end"]
def blob(name):
return open(os.path.join(A, name), "rb").read()
def decode4(chr_, n):
"""4bpp tile n -> 8x8 of palette indices."""
o = n * 32
t = [[0] * 8 for _ in range(8)]
for y in range(8):
p0, p1 = chr_[o + y * 2], chr_[o + y * 2 + 1]
p2, p3 = chr_[o + 16 + y * 2], chr_[o + 16 + y * 2 + 1]
for x in range(8):
b = 7 - x
t[y][x] = (((p0 >> b) & 1) | (((p1 >> b) & 1) << 1)
| (((p2 >> b) & 1) << 2) | (((p3 >> b) & 1) << 3))
return t
def decode2(chr_, n):
o = n * 16
t = [[0] * 8 for _ in range(8)]
for y in range(8):
p0, p1 = chr_[o + y * 2], chr_[o + y * 2 + 1]
for x in range(8):
b = 7 - x
t[y][x] = ((p0 >> b) & 1) | (((p1 >> b) & 1) << 1)
return t
def cgram(d):
pal = []
for i in range(256):
# bit 15 is not part of a BGR555 colour and reads back as bus noise
v = (d[SR_CGR + i * 2] | (d[SR_CGR + i * 2 + 1] << 8)) & 0x7FFF
r = (v & 31) << 3
g = ((v >> 5) & 31) << 3
b = ((v >> 10) & 31) << 3
pal.append((r | r >> 5, g | g >> 5, b | b >> 5))
return pal
def sky_rows():
"""The HDMA table, expanded to one colour per scanline. The backdrop in
CGRAM entry 0 is rewritten every eight lines, so the palette readback only
ever holds the last band -- the gradient has to come from the table."""
hd = blob("sky.hdma")
rows, i, y = [], 0, 0
while i < len(hd) and hd[i] and y < H:
n = hd[i] & 0x7F
v = hd[i + 3] | (hd[i + 4] << 8)
r = (v & 31) << 3
g = ((v >> 5) & 31) << 3
b = ((v >> 10) & 31) << 3
c = (r | r >> 5, g | g >> 5, b | b >> 5)
for _ in range(n):
if y < H:
rows.append(c)
y += 1
i += 5
while len(rows) < H:
rows.append(rows[-1] if rows else (0, 0, 0))
return rows
def draw_bg(px, tmap, chr_, mapw, hofs, vofs, pal, bpp, want_prio):
dec = decode4 if bpp == 4 else decode2
cache = {}
for y in range(H):
wy = (y + vofs) & (32 * 8 - 1)
ty, fy = wy >> 3, wy & 7
for x in range(W):
wx = (x + hofs) & (mapw * 8 - 1)
tx, fx = wx >> 3, wx & 7
scr = tx >> 5
e = (ty * 32 + (tx & 31) + scr * 1024) * 2
wrd = tmap[e] | (tmap[e + 1] << 8)
if ((wrd >> 13) & 1) != want_prio:
continue
n = wrd & 0x3FF
t = cache.get(n)
if t is None:
t = cache[n] = dec(chr_, n)
sx = 7 - fx if wrd & 0x4000 else fx
sy = 7 - fy if wrd & 0x8000 else fy
v = t[sy][sx]
if v == 0:
continue
p = (wrd >> 10) & 7
px[x, y] = pal[p * (16 if bpp == 4 else 4) + v]
def draw_objects(px, oam, chr_, pal):
cache = {}
for i in range(127, -1, -1):
x = oam[i * 4]
y = oam[i * 4 + 1]
tile = oam[i * 4 + 2]
attr = oam[i * 4 + 3]
hi = oam[512 + (i >> 2)]
bits = (hi >> ((i & 3) * 2)) & 3
if bits & 1:
x |= 0x100
if x >= 0x100:
x -= 0x200
size = 32 if bits & 2 else 16
if y >= 0xE0:
continue
base = tile | ((attr & 1) << 8)
p = (attr >> 1) & 7
hf, vf = attr & 0x40, attr & 0x80
n = size // 8
for r in range(n):
for c in range(n):
sr, sc = (n - 1 - r if vf else r), (n - 1 - c if hf else c)
tn = base + sr * 16 + sc
t = cache.get(tn)
if t is None:
t = cache[tn] = decode4(chr_, tn)
for yy in range(8):
py = y + r * 8 + yy
if not 0 <= py < H:
continue
ty = 7 - yy if vf else yy
for xx in range(8):
pxx = x + c * 8 + xx
if not 0 <= pxx < W:
continue
v = t[ty][7 - xx if hf else xx]
if v:
px[pxx, py] = pal[128 + p * 16 + v]
def render(d, base, out):
pal = cgram(d)
oam = d[base:base + OAM_LEN]
bg3 = d[base + BG3_OFF:base + BG3_OFF + 0x800]
camx = d[base + SCROLL_OFF] | (d[base + SCROLL_OFF + 1] << 8)
camy = d[base + SCROLL_OFF + 12] | (d[base + SCROLL_OFF + 13] << 8)
state = d[base + SCROLL_OFF + 2] | (d[base + SCROLL_OFF + 3] << 8)
frame = d[base + SCROLL_OFF + 4] | (d[base + SCROLL_OFF + 5] << 8)
toks = d[base + SCROLL_OFF + 6] | (d[base + SCROLL_OFF + 7] << 8)
coins = d[base + SCROLL_OFF + 8] | (d[base + SCROLL_OFF + 9] << 8)
magic = d[base + SCROLL_OFF + 10] | (d[base + SCROLL_OFF + 11] << 8)
if magic != 0x5A5A:
print("snapshot at $%04X is not stamped" % base, file=sys.stderr)
return None
im = Image.new("RGB", (W, H))
px = im.load()
for y, c in enumerate(sky_rows()):
for x in range(W):
px[x, y] = c
draw_bg(px, blob("clouds.map"), blob("bg2.chr"), 32, camx // 2, camy // 2,
pal, 4, 0)
draw_bg(px, blob("level.map"), blob("bg1.chr"), 64, camx, camy, pal, 4, 0)
draw_objects(px, oam, blob("obj.chr"), pal)
draw_bg(px, bg3, blob("font.chr"), 32, 0, 0, pal, 2, 1)
im.save(out)
im.resize((W * 3, H * 3), Image.NEAREST).save(
out.replace(".png", "_3x.png"))
print("%-28s state %-6s frame %4d tokens %3d coins %3d camera %3d,%d"
% (out, STATE[state] if state < 7 else "?", frame, toks, coins,
camx, camy))
return im
def main(path, outdir):
d = open(path, "rb").read()
if d[8:12] != b"DONE":
print("the dump is not from a finished run", file=sys.stderr)
return 1
os.makedirs(outdir, exist_ok=True)
rc = 0
for name, base in SNAPS.items():
if render(d, base, os.path.join(outdir, "frame_%s.png" % name)) is None:
rc = 1
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "out/game.ram",
sys.argv[2] if len(sys.argv) > 2 else "out/frames"))