191 lines
8.1 KiB
Python
191 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
# Convert ROM-extracted Space Taxi scenes directly into the runtime
|
|
# STL1 binary format (see stLevel.c). Uses the
|
|
# 6502 emulator + raw RAM dump to decompress each scene, then writes
|
|
# screen + color RAM bytes straight into the .dat layout the JoeyLib
|
|
# port loads at runtime.
|
|
#
|
|
# Replaces the .txt -> mkstlevel pipeline for canonical levels. The C
|
|
# mkstlevel tool stays in tree for hand-authored .txt levels.
|
|
#
|
|
# Usage:
|
|
# romToLevel.py <raw-dump> <out-dir> # all 25 screens + title
|
|
# romToLevel.py <raw-dump> <out-dir> <scene_id> # one specific
|
|
#
|
|
# Writes:
|
|
# <out-dir>/level01.dat ... level25.dat (scene 0 = "A" = level01,
|
|
# scene 24 = "Y" = level25)
|
|
# <out-dir>/title.dat
|
|
|
|
import os
|
|
import struct
|
|
import sys
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from cpu6502 import Cpu6502
|
|
from dumpScenes import load_dump, run_scene
|
|
|
|
|
|
# STL2 binary layout (see stLevel.c):
|
|
# "STL2"
|
|
# nameLen + name
|
|
# tileBank, music, bgColor, borderColor, taxiSpawnX, taxiSpawnY
|
|
# xAccel, yAccel, xGrav (int8), yGrav (int8)
|
|
# bgColor1, bgColor2, bgColor3, spriteMc0, spriteMc1, sprite0Color, sprite1Color
|
|
# padCount + padCount * (letter, tileX, tileY, tileW)
|
|
# fareCount + fareCount * (spawnPad, destPad)
|
|
# tilemap[40 * 25]
|
|
# colormap[40 * 25]
|
|
|
|
ST_TILEMAP_W = 40
|
|
ST_PLAYFIELD_ROWS = 25
|
|
ST_TILE_PX = 8
|
|
|
|
# VIC-II sprite-register origins: sprite-X $18 (24) is display column 0,
|
|
# sprite-Y $32 (50) is the first visible raster line. All in-game pad/taxi
|
|
# positions are stored in this sprite-register space, so tile conversion
|
|
# subtracts these origins. PAD_SURFACE_ORIGIN folds in the ~14px landing-
|
|
# gear offset (50-14=36) so a pad's row byte maps to its contact-surface
|
|
# tile row rather than the sprite-top row.
|
|
VIC_X_ORIGIN = 24
|
|
VIC_Y_ORIGIN = 50
|
|
PAD_SURFACE_ORIGIN = 36
|
|
# Screens A..X, then Y: the 25th, " MYSTERY SCREEN !!! ", which $5167
|
|
# loads once the 24-hour shifts have cleared A..X.
|
|
LEVEL_LETTERS = [chr(ord('A') + i) for i in range(25)]
|
|
# The hook blob each level ships: its six trampolines' targets and
|
|
# tables from $7D98 up to the last byte any hook reads. Level Y's
|
|
# program runs on past $8000; its last SFX program is $80C2..$80CA.
|
|
HOOK_FIRST = 0x7D98
|
|
HOOK_END = 0x80CB
|
|
|
|
|
|
def write_level(path, level_index, after, screen, colormap, sprite_blocks):
|
|
"""Write an STL5 level .dat straight from post-decompression RAM.
|
|
|
|
Everything stays in the C64's own units so the runtime can mirror the
|
|
original routines byte for byte:
|
|
name[22] $7D78-$7D8D level name (screen codes, ASCII-compatible)
|
|
header[9] $7D00-$7D08 VIC colours: border, bg, bg1-3, mc0, mc1,
|
|
sprite-0, sprite-1
|
|
padCount $7D5A pads in the table
|
|
specialPad $7D09 fare pads (== padCount, or padCount-1 when
|
|
the last pad is the fuel station)
|
|
fuelRate $7D60 ticks of flight per half fuel cell
|
|
spawn[5] $7D5B-$7D5F X frac, X lo, X hi, Y frac, Y hi (sprite coords)
|
|
templates[8] $7D8F-$7D96 Y accel, X accel, Y gravity, X gravity
|
|
(16-bit little-endian each)
|
|
pads[10][8] $7D0A-$7D59 raw slots: x1 hi/lo, x2 hi/lo, row,
|
|
passenger X frac, passenger X col, unused
|
|
tilemap[1000] screen RAM (all 25 rows, the HUD rows included)
|
|
colormap[1000] colour RAM low nibbles
|
|
levelIndex 0..23 = A..X, 24 = Y, 25 = title
|
|
spriteCount N, then N x (ptr, 63 bytes): the per-level sprite
|
|
bitmaps the level's extra scene loads at $2000+
|
|
hookData[819] $7D98-$80CA: the level's hook code and tables
|
|
"""
|
|
cells = ST_TILEMAP_W * ST_PLAYFIELD_ROWS
|
|
assert len(screen) == cells, f'screen is {len(screen)}, need {cells}'
|
|
assert len(colormap) == cells, f'colormap is {len(colormap)}, need {cells}'
|
|
with open(path, 'wb') as fp:
|
|
fp.write(b'STL5')
|
|
fp.write(bytes(after[0x7D78:0x7D78 + 22]))
|
|
fp.write(bytes(after[0x7D00:0x7D09]))
|
|
fp.write(bytes([after[0x7D5A], after[0x7D09], after[0x7D60]]))
|
|
fp.write(bytes(after[0x7D5B:0x7D60]))
|
|
fp.write(bytes(after[0x7D8F:0x7D97]))
|
|
pad_count = min(after[0x7D5A], 10)
|
|
for pad_idx in range(10):
|
|
if pad_idx < pad_count:
|
|
off = 0x7D0A + pad_idx * 8
|
|
fp.write(bytes(after[off:off + 8]))
|
|
else:
|
|
fp.write(bytes(8))
|
|
fp.write(bytes(screen))
|
|
fp.write(bytes(colormap))
|
|
fp.write(bytes([level_index]))
|
|
fp.write(bytes([len(sprite_blocks)]))
|
|
for ptr, data in sprite_blocks:
|
|
fp.write(bytes([ptr]))
|
|
fp.write(bytes(data))
|
|
fp.write(bytes(after[HOOK_FIRST:HOOK_END]))
|
|
|
|
|
|
# Levels whose data blob flags an extra scene ($7D97 != 0) load a second
|
|
# scene from this table ($6F55, indexed by level) right after the level
|
|
# itself. Those extra scenes only write sprite bitmaps into $2000-$27FF
|
|
# (block pointers $80-$9F) for the level's hook code to animate.
|
|
_EXTRA_SCENE = [0, 0, 0, 0, 0, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0, 0x1F, 0, 0, 0,
|
|
0x20, 0x21, 0, 0, 0, 0x22, 0, 0, 0, 0x23]
|
|
|
|
|
|
def extract_one_level(ram, scene_id, out_dir):
|
|
"""Decompress `scene_id` and write out_dir/level<NN>.dat (NN = scene+1)."""
|
|
letter = LEVEL_LETTERS[scene_id]
|
|
screen_buf, color_buf, after = run_scene(ram, scene_id)
|
|
sprite_blocks = []
|
|
if after[0x7D97] != 0 and _EXTRA_SCENE[scene_id] != 0:
|
|
# Start the extra scene from a zeroed sprite window so the blocks
|
|
# it writes are exactly the ones the level ships with.
|
|
work = bytearray(after)
|
|
for i in range(0x2000, 0x2800):
|
|
work[i] = 0
|
|
_s, _c, after2 = run_scene(bytes(work), _EXTRA_SCENE[scene_id])
|
|
for ptr in range(0x80, 0xA0):
|
|
block = bytes(after2[ptr * 64:ptr * 64 + 63])
|
|
if any(block):
|
|
sprite_blocks.append((ptr, block))
|
|
# The level data itself (pads, hooks) is untouched by the extra
|
|
# scene; keep reading it from the level's own image.
|
|
cells = ST_TILEMAP_W * ST_PLAYFIELD_ROWS
|
|
screen = list(screen_buf[:cells])
|
|
colormap = [(b & 0x0F) for b in color_buf[:cells]]
|
|
level_num = scene_id + 1
|
|
out_path = os.path.join(out_dir, f'level{level_num:02d}.dat')
|
|
write_level(out_path, scene_id, after, screen, colormap, sprite_blocks)
|
|
return out_path, after[0x7D5A], len(sprite_blocks)
|
|
|
|
|
|
def extract_title(ram, out_dir):
|
|
"""The title screen is scene 25 (what $477A decompresses).
|
|
Pads, spawn and templates are dummies."""
|
|
screen_buf, color_buf, after = run_scene(ram, 25)
|
|
cells = ST_TILEMAP_W * ST_PLAYFIELD_ROWS
|
|
screen = list(screen_buf[:cells])
|
|
colormap = [(b & 0x0F) for b in color_buf[:cells]]
|
|
out_path = os.path.join(out_dir, 'title.dat')
|
|
# The title scene writes no level name, so $7D78 still holds whatever
|
|
# the previously decompressed scene left there (PUZZLER, level H).
|
|
# The title never draws the name; ship it blank rather than stale.
|
|
work = bytearray(after)
|
|
work[0x7D78:0x7D78 + 22] = b' ' * 22
|
|
write_level(out_path, 25, work, screen, colormap, [])
|
|
return out_path
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 3:
|
|
print('usage: romToLevel.py <raw-dump> <out-dir> [scene_id]', file=sys.stderr)
|
|
sys.exit(2)
|
|
ram = load_dump(sys.argv[1])
|
|
out_dir = sys.argv[2]
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
if len(sys.argv) >= 4:
|
|
scene_ids = [int(sys.argv[3], 0)]
|
|
else:
|
|
# Screens A..Y = scenes 0..24, then the title (scene 25).
|
|
scene_ids = list(range(len(LEVEL_LETTERS)))
|
|
|
|
for sid in scene_ids:
|
|
path, pad_count, nspr = extract_one_level(ram, sid, out_dir)
|
|
size = os.path.getsize(path)
|
|
print(f' scene {sid:2d} (level {LEVEL_LETTERS[sid]}): pads={pad_count} sprites={nspr} size={size} -> {path}')
|
|
|
|
if len(sys.argv) < 4:
|
|
title_path = extract_title(ram, out_dir)
|
|
print(f' title (scene 25) : -> {title_path} ({os.path.getsize(title_path)} bytes)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|