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"))