Download tools/render_frame.py from elyanlabs/elya-snes: direct link, hf CLI and curl.
- Browser
- Download file 7.53 kB
-
https://huggingface.co/elyanlabs/elya-snes/resolve/main/tools/render_frame.py
- Command line
-
hf download hf://elyanlabs/elya-snes/tools/render_frame.py
-
curl -L -o render_frame.py https://huggingface.co/elyanlabs/elya-snes/resolve/main/tools/render_frame.py
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")) | |