496 lines
19 KiB
Python
496 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
# extractFromDump.py -- convert a VICE C64 memory dump into JoeyLib
|
|
# Space Taxi assets.
|
|
#
|
|
# Reads: /tmp/spacetaxi/mem0000-<labelN>.bin (VICE `save` output;
|
|
# 2-byte LE start-addr header + 64 KB RAM).
|
|
# Writes: examples/spacetaxi/assets/tiles/tilebank<N>.png
|
|
# examples/spacetaxi/assets/levels/level<NN>.txt
|
|
# examples/spacetaxi/assets/sprites/sprites.png (level1 only)
|
|
# examples/spacetaxi/assets/font.png (level1 only)
|
|
#
|
|
# Per-level:
|
|
# - Custom charset at $2800-$2FFF (2 KB, 256 chars * 8 bytes mono)
|
|
# becomes a tile bank PNG: 40 cols x 25 rows of 8x8 cells, with
|
|
# tile index N at cell (N%40, N/40). The PNG uses the EGA-ish
|
|
# 16-color palette we already install on stage.
|
|
# - Screen RAM at $0400-$07E7 (1000 bytes = 40x25 char codes) and
|
|
# color RAM at $D800-$DBE7 (1000 bytes = 40x25 palette nybbles)
|
|
# become the tilemap+colormap in the level .txt source.
|
|
#
|
|
# Sprite data at $3000-$37FF + sprite pointer table at $07F8-$07FF
|
|
# is decoded once (it's the same across levels for the active set):
|
|
# - sprite 0 / 7: the taxi cab body & shadow
|
|
# - sprite 1..6: passenger frames / animation cels
|
|
# 64 bytes per sprite, 24x21 1-bit, MSB-leftmost. Color comes from
|
|
# $D027-$D02E (one color per sprite).
|
|
#
|
|
# Usage:
|
|
# python3 extractFromDump.py stuff/spacetaxi/mem0000-level1.bin level01 1
|
|
# python3 extractFromDump.py stuff/spacetaxi/mem0000-level2.bin level02 2
|
|
# args: <dump-path> <level-name-without-extension> <tilebank-id>
|
|
#
|
|
# Dumps live in stuff/spacetaxi/ (committed-ish, repo-local) rather
|
|
# than /tmp so they survive reboots and stay with the project.
|
|
|
|
import os
|
|
import struct
|
|
import sys
|
|
from collections import Counter
|
|
|
|
from PIL import Image
|
|
|
|
|
|
# C64 standard color palette (EGA-ish 16-color JoeyLib mapping).
|
|
# Indices 0..15 match the C64 VIC-II color register order so a value
|
|
# read from $D8xx or $D02x maps directly to a JoeyLib palette slot.
|
|
# Colors below approximate Pepto's standard PAL palette in $RGB form.
|
|
C64_PALETTE_RGB = [
|
|
(0x00, 0x00, 0x00), # 0 black
|
|
(0xFF, 0xFF, 0xFF), # 1 white
|
|
(0x88, 0x39, 0x32), # 2 red
|
|
(0x67, 0xB6, 0xBD), # 3 cyan
|
|
(0x8B, 0x3F, 0x96), # 4 purple
|
|
(0x55, 0xA0, 0x49), # 5 green
|
|
(0x40, 0x31, 0x8D), # 6 blue
|
|
(0xBF, 0xCE, 0x72), # 7 yellow
|
|
(0x8B, 0x54, 0x29), # 8 orange
|
|
(0x57, 0x42, 0x00), # 9 brown
|
|
(0xB8, 0x69, 0x62), # 10 light red
|
|
(0x50, 0x50, 0x50), # 11 dark gray
|
|
(0x78, 0x78, 0x78), # 12 mid gray
|
|
(0x94, 0xE0, 0x89), # 13 light green
|
|
(0x78, 0x69, 0xC4), # 14 light blue
|
|
(0x9F, 0x9F, 0x9F), # 15 light gray
|
|
]
|
|
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def load_dump(path):
|
|
with open(path, "rb") as f:
|
|
raw = f.read()
|
|
start = raw[0] | (raw[1] << 8)
|
|
if start != 0:
|
|
raise SystemExit(f"{path}: unexpected start ${start:04X}, want $0000")
|
|
if len(raw) - 2 != 0x10000:
|
|
raise SystemExit(f"{path}: not a 64K dump (got {len(raw) - 2} bytes)")
|
|
return raw[2:]
|
|
|
|
|
|
def new_canvas(w, h):
|
|
img = Image.new("P", (w, h), 0)
|
|
flat = bytearray()
|
|
for r, g, b in C64_PALETTE_RGB:
|
|
flat.extend((r, g, b))
|
|
flat.extend(b"\x00" * (768 - len(flat)))
|
|
img.putpalette(bytes(flat))
|
|
return img
|
|
|
|
|
|
def paint_charset_glyph(px, mem, src_code, bx, by):
|
|
"""Plot the 8x8 glyph for C64 screen code `src_code` at pixel
|
|
(bx, by) of the indexed-palette PIL image. Foreground = palette
|
|
index 1, background untouched (already index 0 from new_canvas).
|
|
"""
|
|
src = 0x2800 + src_code * 8
|
|
for r in range(8):
|
|
byte = mem[src + r]
|
|
for c in range(8):
|
|
if byte & (0x80 >> c):
|
|
px[bx + c, by + r] = 1
|
|
|
|
|
|
def extract_tilebank(mem, out_path, code_to_slot=None):
|
|
"""Render the custom charset at $2800-$2FFF as a 320x200 PNG.
|
|
When `code_to_slot` is None, lays out all 256 chars at their raw
|
|
C64 positions (40 cols x 25 rows). When `code_to_slot` is given,
|
|
writes each charset glyph at the slot the level loader expects
|
|
-- i.e. the bank tile at position N is the glyph for screen-code
|
|
`code_for_slot[N]`. That keeps level data and tilebank aligned
|
|
after the level extractor's frequency-sorted code remapping.
|
|
"""
|
|
img = new_canvas(320, 200)
|
|
px = img.load()
|
|
if code_to_slot is None:
|
|
for ch in range(256):
|
|
paint_charset_glyph(px, mem, ch, (ch % 40) * 8, (ch // 40) * 8)
|
|
else:
|
|
# Invert: slot -> code. Multiple codes can collide into slot 0
|
|
# (background); only the first one we see "owns" that slot for
|
|
# painting purposes -- the rest just fall through to the same
|
|
# blank tile. Highest priority is the explicit space mapping.
|
|
slot_to_code = {}
|
|
for code, slot in code_to_slot.items():
|
|
slot_to_code.setdefault(slot, code)
|
|
for slot, code in slot_to_code.items():
|
|
if slot >= 256:
|
|
continue
|
|
paint_charset_glyph(px, mem, code, (slot % 40) * 8, (slot // 40) * 8)
|
|
img.save(out_path, "PNG", optimize=True)
|
|
|
|
|
|
def build_code_to_slot(mem):
|
|
"""Build the screen-code -> alphabet-slot map shared between the
|
|
tilebank emission and the level emission. The slot index is what
|
|
the runtime sees in level.tilemap[]; the tilebank must mirror this
|
|
ordering so a level-data slot-N cell renders the charset glyph
|
|
that was actually used in the original screen RAM at that code.
|
|
"""
|
|
screen = mem[0x0400:0x0400 + 1000]
|
|
codes = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0]))
|
|
code_to_slot = {}
|
|
next_slot = 0
|
|
for code, _count in codes:
|
|
if code == 0x20: # space -> always index 0
|
|
code_to_slot[code] = 0
|
|
continue
|
|
if code in code_to_slot:
|
|
continue
|
|
if next_slot >= len(ALPHABET):
|
|
code_to_slot[code] = 0
|
|
continue
|
|
# Reserve slot 0 for background (space). The very first
|
|
# non-space code claims slot 1, etc.
|
|
if next_slot == 0:
|
|
next_slot = 1
|
|
code_to_slot[code] = next_slot
|
|
next_slot += 1
|
|
return code_to_slot
|
|
|
|
|
|
def screencode_to_char(code):
|
|
"""Convert a C64 screen code into a printable tilemap character
|
|
using the encoding the .txt format already understands:
|
|
0 -> '.' (treated as 0)
|
|
1..9 -> '1'..'9' (digits encode the lower 10 indices)
|
|
but we shift to use up most of the index space below.
|
|
For arbitrary 0..255 we need a 256-symbol alphabet -- not feasible
|
|
in a single character per cell. So we encode as TWO chars per cell
|
|
using base-16 hex would still only cover 0..255. Decision: keep
|
|
.txt cells one-char and map screen codes via a custom 64-char
|
|
alphabet plus an @char directive table emitted into the file so
|
|
the loader knows exactly which screen code each char represents.
|
|
"""
|
|
# This function is unused as a one-char encoder; we encode via the
|
|
# generic alphabet below instead.
|
|
return code
|
|
|
|
|
|
# Generic 1-char alphabet (62 codes); the level loader's index map
|
|
# was: '.' or ' '=0, '0'..'9'=0..9, 'A'..'Z'=10..35, 'a'..'z'=36..61.
|
|
# That's only 62 codes, not 256. The original C64 levels use ~30
|
|
# distinct screen codes per level so most levels fit. We map the N
|
|
# most-frequent screen codes to those alphabet slots.
|
|
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
|
|
def extract_level(mem, level_name, tilebank_id, out_path, code_to_alpha):
|
|
"""Render screen RAM + color RAM as an STL1-format .txt source
|
|
using the prebuilt screen-code -> alphabet-slot mapping. Caller
|
|
must pass the same mapping that was used to reorder the tilebank
|
|
PNG; otherwise the level tilemap and tilebank diverge.
|
|
"""
|
|
screen = mem[0x0400:0x0400 + 1000] # 40x25
|
|
color = mem[0xD800:0xD800 + 1000] # 40x25, low nybble = palette slot
|
|
codes = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0]))
|
|
|
|
# Full 25-row C64 screen. The title screen uses all 25 rows
|
|
# (PUBLISHED ... SOFTWARE credit wraps across rows 21, 22, 24
|
|
# with frame chars interleaved). Gameplay HUD draws over the
|
|
# bottom 3 rows at runtime.
|
|
playfield_rows = 25
|
|
|
|
lines = []
|
|
lines.append(f"# Extracted from VICE memory dump. Edit at your own risk;")
|
|
lines.append(f"# regenerating will overwrite. Keep the @tile mappings in")
|
|
lines.append(f"# sync with the tilebank PNG (extracted from $2800).")
|
|
lines.append(f"")
|
|
lines.append(f"@name {level_name.upper()}")
|
|
lines.append(f"@tilebank {tilebank_id}")
|
|
lines.append(f"@music 0")
|
|
bg = mem[0xD021] & 0x0F
|
|
border = mem[0xD020] & 0x0F
|
|
lines.append(f"@bgColor {bg}")
|
|
lines.append(f"@borderColor {border}")
|
|
|
|
# Best-effort taxi spawn: middle-top of playfield. The original
|
|
# script-driven spawn position would need to be lifted out of
|
|
# game state at dump time; for now hand-edit if you want it
|
|
# to match the cracker's preferred spawn.
|
|
lines.append(f"@taxiSpawn 18 2")
|
|
lines.append(f"")
|
|
|
|
# Pads: scan the playfield for "landable surfaces" -- runs of
|
|
# >= 3 identical non-background tile cells with two or more
|
|
# empty rows directly above (so the taxi can approach from the
|
|
# top). Per-row scanning runs top-to-bottom; for the same
|
|
# surface tile across rows we only take the topmost contiguous
|
|
# platform edge.
|
|
bg_code = codes[0][0] if codes else 0x20
|
|
def is_bg(code):
|
|
return code == bg_code or code == 0x20
|
|
|
|
pads = []
|
|
seen_used = [False] * (playfield_rows * 40)
|
|
for row in range(2, playfield_rows):
|
|
line = screen[row * 40:(row + 1) * 40]
|
|
prev = screen[(row - 1) * 40:row * 40]
|
|
prev2 = screen[(row - 2) * 40:(row - 1) * 40]
|
|
col = 0
|
|
while col < 40 and len(pads) < 8:
|
|
if is_bg(line[col]):
|
|
col += 1
|
|
continue
|
|
j = col
|
|
while j < 40 and line[j] == line[col] and not seen_used[row * 40 + j]:
|
|
j += 1
|
|
width = j - col
|
|
# Approachable from above? At least 2/3 of cells in the
|
|
# 2 rows above this stretch must be background.
|
|
above_clear = 0
|
|
for k in range(col, j):
|
|
if is_bg(prev[k]):
|
|
above_clear += 1
|
|
if is_bg(prev2[k]):
|
|
above_clear += 1
|
|
if width >= 3 and above_clear >= width:
|
|
pads.append((row, col, width))
|
|
for k in range(col, j):
|
|
seen_used[row * 40 + k] = True
|
|
col = j
|
|
|
|
lines.append("# Pads auto-detected from landable surfaces (>= 3 wide,")
|
|
lines.append("# approachable from above). Verify and adjust as needed.")
|
|
pad_letter = ord("A")
|
|
for (row, col, width) in pads:
|
|
if pad_letter > ord("H"):
|
|
break
|
|
lines.append(f"@pad {chr(pad_letter)} {col} {row} {width}")
|
|
pad_letter += 1
|
|
if pad_letter == ord("A"):
|
|
lines.append("# (No landable surfaces auto-detected; emitting placeholders.)")
|
|
lines.append("@pad A 4 10 4")
|
|
lines.append("@pad B 32 10 4")
|
|
pad_letter = ord("C")
|
|
|
|
lines.append("")
|
|
# Fare list: simple round-robin between detected pads.
|
|
pad_count = pad_letter - ord("A")
|
|
if pad_count >= 2:
|
|
for k in range(pad_count):
|
|
src = chr(ord("A") + k)
|
|
dst = chr(ord("A") + ((k + 1) % pad_count))
|
|
lines.append(f"@fare {src} {dst} 45")
|
|
else:
|
|
lines.append("@fare A B 45")
|
|
lines.append("")
|
|
|
|
# Emit the @tile mapping for round-trip clarity (and so the
|
|
# author can read a level .txt and tell which screen code maps
|
|
# to which char). Format: @tile <char> <screencode> -- the
|
|
# mkstlevel parser ignores these today but they're useful
|
|
# documentation, and a future loader could honor them.
|
|
lines.append("# Screen-code -> alphabet-char mapping (one per unique code).")
|
|
inv = {v: k for k, v in code_to_alpha.items()}
|
|
for idx in sorted(inv):
|
|
ch = '.' if idx == 0 else ALPHABET[idx]
|
|
code = inv[idx]
|
|
lines.append(f"# @tile '{ch}' = $C64_screencode_{code:02X}")
|
|
lines.append("")
|
|
|
|
# Tilemap (22 rows of 40 chars each).
|
|
lines.append("@tilemap")
|
|
for r in range(playfield_rows):
|
|
row = screen[r * 40:(r + 1) * 40]
|
|
s = ""
|
|
for code in row:
|
|
idx = code_to_alpha.get(code, 0)
|
|
s += '.' if idx == 0 else ALPHABET[idx]
|
|
lines.append(s)
|
|
|
|
lines.append("")
|
|
lines.append("@colormap")
|
|
for r in range(playfield_rows):
|
|
row = color[r * 40:(r + 1) * 40]
|
|
s = "".join(
|
|
('0' if (c & 0x0F) == 0
|
|
else ALPHABET[(c & 0x0F)]
|
|
if (c & 0x0F) < len(ALPHABET) else '0')
|
|
for c in row
|
|
)
|
|
lines.append(s)
|
|
|
|
with open(out_path, "w") as fp:
|
|
fp.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def extract_sprites(mem, out_path):
|
|
"""Render C64 hardware sprites into the JoeyLib-expected sprite
|
|
sheet layout (320x200, 16-color indexed PNG):
|
|
y= 0..23 : 1 taxi cel (24x24, x = 0)
|
|
sprite 0 ptr (in level1 dump: $DC -> $3700)
|
|
The adjacent slot $DD is the landing-gear-down
|
|
pose, not a thrust frame -- ignored for now.
|
|
y= 24..47 : 4 passenger cels (16x16 each, x = 0, 16, 32, 48)
|
|
Sprite 3 ptr ($07FB) is the passenger data; sprites 3-6 share it.
|
|
"""
|
|
img = new_canvas(320, 200)
|
|
px = img.load()
|
|
|
|
# Shared multicolor sprite colors from VIC registers.
|
|
mcm_color0 = mem[0xD025] & 0x0F # 2-bit value 01
|
|
mcm_color1 = mem[0xD026] & 0x0F # 2-bit value 11
|
|
mcm_mask = mem[0xD01C] # bit per sprite: 1 = multicolor
|
|
|
|
def paint_sprite_at(data_addr, sprite_color, dst_x, dst_y, dst_w, dst_h, multicolor=False):
|
|
# Mono C64 sprite is 24x21, 1 bit per pixel, 3 bytes per row.
|
|
# Multicolor C64 sprite is 12 "double-wide pixels" x 21 rows,
|
|
# 2 bits per pixel, still 3 bytes per row. Color mapping:
|
|
# 00 -> transparent
|
|
# 01 -> mcm_color0 ($D025)
|
|
# 10 -> sprite_color (sprite's own $D027+sp)
|
|
# 11 -> mcm_color1 ($D026)
|
|
if sprite_color == 0:
|
|
sprite_color = 1
|
|
sx_max = min(24, dst_w)
|
|
sy_max = min(21, dst_h)
|
|
for r in range(sy_max):
|
|
if multicolor:
|
|
for byte_idx in range(3):
|
|
byte = mem[data_addr + r * 3 + byte_idx]
|
|
# 4 multicolor pixels per byte; each spans 2
|
|
# hardware pixels wide.
|
|
for pp in range(4):
|
|
bits = (byte >> ((3 - pp) * 2)) & 0x03
|
|
if bits == 0:
|
|
continue
|
|
if bits == 1:
|
|
col = mcm_color0
|
|
elif bits == 2:
|
|
col = sprite_color
|
|
else:
|
|
col = mcm_color1
|
|
sx0 = byte_idx * 8 + pp * 2
|
|
if sx0 + 1 >= sx_max:
|
|
break
|
|
px[dst_x + sx0, dst_y + r] = col
|
|
px[dst_x + sx0 + 1, dst_y + r] = col
|
|
else:
|
|
for byte_idx in range(3):
|
|
byte = mem[data_addr + r * 3 + byte_idx]
|
|
for bit in range(8):
|
|
sx = byte_idx * 8 + bit
|
|
if sx >= sx_max:
|
|
break
|
|
if byte & (0x80 >> bit):
|
|
px[dst_x + sx, dst_y + r] = sprite_color
|
|
|
|
def paint_c64_sprite(sp_index, dst_x, dst_y, dst_w, dst_h):
|
|
ptr = mem[0x07F8 + sp_index]
|
|
if ptr == 0:
|
|
return False
|
|
sprite_color = mem[0xD027 + sp_index] & 0x0F
|
|
is_multicolor = bool(mcm_mask & (1 << sp_index))
|
|
paint_sprite_at(ptr * 64, sprite_color, dst_x, dst_y, dst_w, dst_h,
|
|
multicolor=is_multicolor)
|
|
return True
|
|
|
|
# Single taxi cel from sprite 0's actual pointer.
|
|
paint_c64_sprite(0, 0, 0, 24, 24)
|
|
|
|
# 4 passenger cels = the shared C64 sprite 3 (sprites 3..6 all
|
|
# point to the same data in the level-1 dump).
|
|
for cel in range(4):
|
|
paint_c64_sprite(3, cel * 16, 24, 16, 16)
|
|
|
|
img.save(out_path, "PNG", optimize=True)
|
|
|
|
|
|
def extract_font(mem, out_path):
|
|
"""Render the custom charset (which IS the in-game font in C64
|
|
text-mode games) as the 320x200 font.png. Same layout as
|
|
tilebank, but here we want each glyph at cell (ascii%40,
|
|
ascii/40) for jlDrawText's ASCII map.
|
|
|
|
For Space Taxi specifically, the charset doubles as the level
|
|
tile bank AND any in-screen text Sierra-style. We map the C64
|
|
screen codes used for ASCII display (codes 1..26 = A..Z,
|
|
32..63 = punctuation/digits) directly to their ASCII positions
|
|
in the JoeyLib font sheet.
|
|
"""
|
|
img = new_canvas(320, 200)
|
|
px = img.load()
|
|
|
|
def render_at(ascii_code, charset_idx):
|
|
if ascii_code < 32 or ascii_code >= 128:
|
|
return
|
|
src = 0x2800 + (charset_idx & 0xFF) * 8
|
|
col = ascii_code % 40
|
|
row = ascii_code // 40
|
|
bx = col * 8
|
|
by = row * 8
|
|
for r in range(8):
|
|
byte = mem[src + r]
|
|
for c in range(8):
|
|
if byte & (0x80 >> c):
|
|
px[bx + c, by + r] = 1
|
|
|
|
# C64 upper-only screen codes: 1..26 = A..Z, 27=[ 28=£ 29=] 30=↑ 31=←
|
|
# 32 = space, 33..63 = !"# ... ?, etc. We map ASCII directly.
|
|
for c in range(32, 64):
|
|
render_at(c, c) # symbols + digits 1:1
|
|
for c, code in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
|
|
render_at(ord(code), 1 + c) # screen code 1..26 -> A..Z
|
|
|
|
img.save(out_path, "PNG", optimize=True)
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 4:
|
|
print("usage: extractFromDump.py <dump-path> <level-name> <tilebank-id>", file=sys.stderr)
|
|
sys.exit(2)
|
|
dump_path = sys.argv[1]
|
|
level_name = sys.argv[2]
|
|
tilebank_id = int(sys.argv[3])
|
|
|
|
mem = load_dump(dump_path)
|
|
print(f"loaded {dump_path}: 64 KB OK")
|
|
|
|
tiles_dir = os.path.join(ROOT, "assets", "tiles")
|
|
sprites_dir = os.path.join(ROOT, "assets", "sprites")
|
|
levels_dir = os.path.join(ROOT, "assets", "levels")
|
|
os.makedirs(tiles_dir, exist_ok=True)
|
|
os.makedirs(sprites_dir, exist_ok=True)
|
|
os.makedirs(levels_dir, exist_ok=True)
|
|
|
|
# Build the screen-code -> bank-slot mapping once and feed it to
|
|
# both extractors so the tilemap and tilebank stay aligned.
|
|
code_to_slot = build_code_to_slot(mem)
|
|
|
|
# 8.3 DOS naming -- "tilebank0" is 9 chars and fopen fails under
|
|
# DOSBox strict 8.3 mode. Keep the short form across the pipeline.
|
|
tilebank_path = os.path.join(tiles_dir, f"tbank{tilebank_id}.png")
|
|
extract_tilebank(mem, tilebank_path, code_to_slot)
|
|
print(f" wrote {tilebank_path}")
|
|
|
|
level_path = os.path.join(levels_dir, f"{level_name}.txt")
|
|
extract_level(mem, level_name, tilebank_id, level_path, code_to_slot)
|
|
print(f" wrote {level_path}")
|
|
|
|
# Sprites + font come from level 1 only (sprite pointer table is
|
|
# script-driven so it differs by scene; we use the first dump's
|
|
# set as the canonical sheet, plus its charset as the font).
|
|
if tilebank_id == 1:
|
|
sprite_path = os.path.join(sprites_dir, "sprites.png")
|
|
extract_sprites(mem, sprite_path)
|
|
print(f" wrote {sprite_path}")
|
|
|
|
font_path = os.path.join(ROOT, "assets", "font.png")
|
|
extract_font(mem, font_path)
|
|
print(f" wrote {font_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|