File size: 7,532 Bytes
8e0f023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | #!/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"))
|