389 lines
14 KiB
Python
389 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
sceneryRender - render an FS2 scenery snapshot from the extracted database.
|
|
|
|
Walks the chunk5 bytecode using the polygon-rendering subset of opcodes
|
|
($07 EnterLocalFrame, $24 PushOriginWithStash, $12 SetColor, $40/$41
|
|
xform-B emit, $01/$02 xform-A emit, $31/$42 cache fill, $32/$33/$35
|
|
cached vertex). Uses MAME's captured matrix for the camera transform so
|
|
the output is bit-equivalent to running the original on real hardware.
|
|
|
|
Usage:
|
|
sceneryRender.py FS2.1.json [--out path.ppm]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
# Viewport (Apple II hires above the panel).
|
|
NATIVE_WIDTH = 280
|
|
NATIVE_HEIGHT = 192
|
|
VIEWPORT_BOTTOM = 99 # Last row of the scenery viewport.
|
|
|
|
# Camera state captured from MAME at the Meigs boot moment.
|
|
# $5C/$5D = camera X (scenery units), $64/$65 = camera Z, $5E/$5F = altitude.
|
|
CAMERA_X_SCENERY = 287
|
|
CAMERA_Y_SCENERY = 25 # = altitude in metres for MAME boot
|
|
CAMERA_Z_SCENERY = 804
|
|
|
|
# 3x3 view matrix at boot. Captured from MAME runtime ZP $78-$89.
|
|
# Q1.14 entries (signed 16-bit, bit 14 = 1.0).
|
|
MATRIX = (
|
|
(16382, 0, 0),
|
|
( 0, 32760, 100),
|
|
( 0, -401, 8190),
|
|
)
|
|
|
|
# Palette codes -> 24-bit RGB (chunk5 ToHiresColorTable mapping).
|
|
COLORS = {
|
|
0x00: (0, 0, 0),
|
|
0x01: (63, 125, 47), # ground-day green
|
|
0x02: (42, 93, 168), # water blue
|
|
0x03: (63, 125, 47),
|
|
0x04: (42, 93, 168),
|
|
0x05: (0, 0, 0),
|
|
0x06: (139, 134, 128), # runway grey
|
|
0x07: (160, 96, 64), # building tan/red
|
|
0x08: (0, 0, 0),
|
|
0x09: (224, 224, 224),
|
|
0x0A: (156, 122, 79),
|
|
0x0B: (42, 85, 32),
|
|
0x0C: (110, 90, 63),
|
|
0x0D: (240, 240, 240), # white
|
|
0x0E: (200, 224, 240),
|
|
0x0F: (176, 168, 120), # CITY tan
|
|
}
|
|
SKY_COLOR = (107, 163, 220)
|
|
GROUND_COLOR = (63, 125, 47)
|
|
DEFAULT_LINE_COLOR = (240, 240, 240)
|
|
|
|
|
|
# -- bytecode walker that renders ---------------------------------------------
|
|
|
|
class State:
|
|
def __init__(self):
|
|
# Camera-vs-section delta ($66/$67=X, $68/$69=Y, $6A/$6B=Z).
|
|
# Per port: at boot, $66=$6A=0, $68 = camera Y in scenery units.
|
|
self.deltaX = 0
|
|
self.deltaY = CAMERA_Y_SCENERY
|
|
self.deltaZ = 0
|
|
# Cached vertex pool ($0140 + idx*8). Each entry: (x, y, z, outcode).
|
|
self.cache = [None] * 64
|
|
# V1 / V2 holding cells.
|
|
self.v1 = (0, 0, 0, 0) # (x, y, z, outcode)
|
|
self.v2 = (0, 0, 0, 0)
|
|
# Color (palette code).
|
|
self.colorCode = 0x0D # white default
|
|
# Day-only suppression (chunk5 $1B clears, $1C sets at night).
|
|
self.dayOnlySkip = False
|
|
# Polygon-state $D3 (= outcode AND of accumulated primary verts).
|
|
self.d3 = 0
|
|
# All emitted segments: (x1,y1,z1, x2,y2,z2, color_code).
|
|
self.segments = []
|
|
# All cached-vertex emits (for stats).
|
|
self.cacheFills = 0
|
|
|
|
|
|
# -- transform & projection ---------------------------------------------------
|
|
|
|
def transform7EBC(state, vx, vz):
|
|
"""xform-B: subtract camera delta then multiply by view matrix.
|
|
|
|
chunk5 TransformVertex7EBC reads X and Z from the stream (no Y; Y comes
|
|
from the section base, accumulated by EnterLocalFrame). The result is
|
|
the camera-relative (X, Y, Z) ready for projection.
|
|
"""
|
|
dx = vx - state.deltaX
|
|
dy = -state.deltaY # local Y = 0 - camera Y
|
|
dz = vz - state.deltaZ
|
|
return _matmul(dx, dy, dz)
|
|
|
|
|
|
def transform80C5(state, vx, vy, vz):
|
|
"""xform-A: 6-byte X/Y/Z stream; Y is per-vertex."""
|
|
dx = vx - state.deltaX
|
|
dy = vy - state.deltaY
|
|
dz = vz - state.deltaZ
|
|
return _matmul(dx, dy, dz)
|
|
|
|
|
|
def _matmul(dx, dy, dz):
|
|
# Q1.14 matrix * Q0 vector. Output Q0 (world units).
|
|
# chunk5 normalises by >> 14 (or 16 for some paths); use >> 14 to match.
|
|
rx = (MATRIX[0][0] * dx + MATRIX[0][1] * dy + MATRIX[0][2] * dz) >> 14
|
|
ry = (MATRIX[1][0] * dx + MATRIX[1][1] * dy + MATRIX[1][2] * dz) >> 14
|
|
rz = (MATRIX[2][0] * dx + MATRIX[2][1] * dy + MATRIX[2][2] * dz) >> 14
|
|
return (rx, ry, rz)
|
|
|
|
|
|
def classifyOutcode(v):
|
|
"""6-bit frustum outcode (bit 7 = behind-camera valid flag)."""
|
|
x, y, z = v
|
|
code = 0
|
|
if z <= 0:
|
|
code |= 0x20
|
|
return code | 0x80
|
|
if x < -z: code |= 0x01
|
|
if x > z: code |= 0x02
|
|
if y < -z: code |= 0x04
|
|
if y > z: code |= 0x08
|
|
return code | 0x80
|
|
|
|
|
|
def project(v):
|
|
x, y, z = v
|
|
if z <= 0:
|
|
return None
|
|
qx = (x * 128) // z
|
|
qy = (y * 128) // z
|
|
cx = NATIVE_WIDTH // 2
|
|
cy = VIEWPORT_BOTTOM // 2
|
|
sx = cx + (qx * (NATIVE_WIDTH // 2)) // 0x7F
|
|
sy = cy - (qy * (VIEWPORT_BOTTOM // 2)) // 0x7F
|
|
return (sx, sy)
|
|
|
|
|
|
def clipNearPlane(a, b):
|
|
if a[2] > 0 and b[2] > 0:
|
|
return a, b
|
|
if a[2] <= 0 and b[2] <= 0:
|
|
return None
|
|
behind, front = (a, b) if a[2] <= 0 else (b, a)
|
|
denom = front[2] - behind[2]
|
|
if denom <= 0:
|
|
return None
|
|
t = ((1 - behind[2]) << 8) // denom
|
|
if t < 0: t = 0
|
|
if t > 256: t = 256
|
|
bx = behind[0] + ((front[0] - behind[0]) * t) // 256
|
|
by = behind[1] + ((front[1] - behind[1]) * t) // 256
|
|
return (a, (bx, by, 1)) if b is behind else ((bx, by, 1), b)
|
|
|
|
|
|
# -- bytecode interpreter ----------------------------------------------------
|
|
|
|
def runSection(secOps, state):
|
|
"""Walk a section's already-decoded ops in pc order. The extractor's
|
|
walker followed both branches of every conditional, so we just iterate
|
|
in pc order and process the rendering-relevant ops.
|
|
"""
|
|
for op in sorted(secOps, key=lambda o: o['pc']):
|
|
code = op['op']
|
|
if code == 0x12: # SetColor
|
|
state.colorCode = op['color_code']
|
|
elif code == 0x1B: # ModeWhite
|
|
state.dayOnlySkip = False
|
|
state.colorCode = 0x0D
|
|
elif code == 0x1C: # DayOnly
|
|
pass # daytime render: don't suppress
|
|
elif code == 0x07: # EnterLocalFrame
|
|
# Layout: $07, base, x*3, y*3, z*3, dx*2, dy*2 = 14 bytes.
|
|
# For now: extract section base and shift camera delta.
|
|
b = op['bytes']
|
|
if len(b) >= 8:
|
|
bx = _signed16(b[2], b[3])
|
|
by = _signed16(b[4], b[5])
|
|
bz = _signed16(b[6], b[7])
|
|
state.deltaX += bx
|
|
state.deltaY += by
|
|
state.deltaZ += bz
|
|
elif code == 0x24: # PushOriginWithStash
|
|
# Layout: $24, dx*2, dy*2, dz*2 = 8 bytes (approx).
|
|
# Pushes a new origin onto the section stack.
|
|
b = op['bytes']
|
|
if len(b) >= 8:
|
|
bx = _signed16(b[2], b[3])
|
|
by = _signed16(b[4], b[5])
|
|
bz = _signed16(b[6], b[7])
|
|
state.deltaX += bx
|
|
state.deltaY += by
|
|
state.deltaZ += bz
|
|
elif code == 0x40: # EmitV1Xform7EBC silent
|
|
if 'vx' not in op or 'vz' not in op: continue
|
|
v = transform7EBC(state, op['vx'], op['vz'])
|
|
state.v1 = (*v, classifyOutcode(v))
|
|
elif code == 0x41: # EmitV2Xform7EBC + draw
|
|
if 'vx' not in op or 'vz' not in op: continue
|
|
v = transform7EBC(state, op['vx'], op['vz'])
|
|
state.v2 = (*v, classifyOutcode(v))
|
|
_emitSegment(state)
|
|
state.v1 = state.v2
|
|
elif code == 0x01: # EmitV1Xform80C5 silent
|
|
if not all(k in op for k in ('vx','vy','vz')): continue
|
|
v = transform80C5(state, op['vx'], op['vy'], op['vz'])
|
|
state.v1 = (*v, classifyOutcode(v))
|
|
elif code == 0x02: # EmitV2Xform80C5 + draw
|
|
if not all(k in op for k in ('vx','vy','vz')): continue
|
|
v = transform80C5(state, op['vx'], op['vy'], op['vz'])
|
|
state.v2 = (*v, classifyOutcode(v))
|
|
_emitSegment(state)
|
|
state.v1 = state.v2
|
|
elif code == 0x42: # cache fill xform-B
|
|
if 'cache_idx' not in op or 'vx' not in op: continue
|
|
v = transform7EBC(state, op['vx'], op['vz'])
|
|
state.cache[op['cache_idx']] = (*v, classifyOutcode(v))
|
|
state.cacheFills += 1
|
|
elif code == 0x31: # cache fill xform-A
|
|
if not all(k in op for k in ('cache_idx','vx','vy','vz')): continue
|
|
v = transform80C5(state, op['vx'], op['vy'], op['vz'])
|
|
state.cache[op['cache_idx']] = (*v, classifyOutcode(v))
|
|
state.cacheFills += 1
|
|
elif code == 0x32: # cached V1 (silent)
|
|
if 'cache_idx' not in op: continue
|
|
cv = state.cache[op['cache_idx']]
|
|
if cv is not None:
|
|
state.v1 = cv
|
|
elif code == 0x33: # cached V2 + draw V1->V2
|
|
if 'cache_idx' not in op: continue
|
|
cv = state.cache[op['cache_idx']]
|
|
if cv is not None:
|
|
state.v2 = cv
|
|
_emitSegment(state)
|
|
state.v1 = state.v2
|
|
elif code == 0x35: # cached + plot pixel
|
|
if 'cache_idx' not in op: continue
|
|
cv = state.cache[op['cache_idx']]
|
|
if cv is not None:
|
|
state.v1 = cv
|
|
|
|
|
|
def _emitSegment(state):
|
|
if state.dayOnlySkip:
|
|
return
|
|
v1 = state.v1
|
|
v2 = state.v2
|
|
# Outcode AND -> entirely off-screen on one side.
|
|
if (v1[3] & v2[3] & 0x3F) != 0:
|
|
return
|
|
state.segments.append((v1[0], v1[1], v1[2], v2[0], v2[1], v2[2], state.colorCode))
|
|
|
|
|
|
def _signed16(lo, hi):
|
|
v = lo | (hi << 8)
|
|
return v - 0x10000 if v >= 0x8000 else v
|
|
|
|
|
|
# -- framebuffer + draw ------------------------------------------------------
|
|
|
|
class FB:
|
|
def __init__(self):
|
|
self.pix = bytearray(NATIVE_WIDTH * NATIVE_HEIGHT * 3)
|
|
|
|
def fillRow(self, y, color):
|
|
for x in range(NATIVE_WIDTH):
|
|
self.setPixel(x, y, color)
|
|
|
|
def setPixel(self, x, y, color):
|
|
if 0 <= x < NATIVE_WIDTH and 0 <= y < NATIVE_HEIGHT:
|
|
i = (y * NATIVE_WIDTH + x) * 3
|
|
self.pix[i] = color[0]
|
|
self.pix[i+1] = color[1]
|
|
self.pix[i+2] = color[2]
|
|
|
|
def line(self, x1, y1, x2, y2, color):
|
|
# Bresenham, clipped to viewport.
|
|
dx = abs(x2 - x1); sx = 1 if x1 < x2 else -1
|
|
dy = -abs(y2 - y1); sy = 1 if y1 < y2 else -1
|
|
err = dx + dy
|
|
steps = 0
|
|
while True:
|
|
self.setPixel(x1, y1, color)
|
|
if x1 == x2 and y1 == y2: break
|
|
e2 = 2 * err
|
|
if e2 >= dy: err += dy; x1 += sx
|
|
if e2 <= dx: err += dx; y1 += sy
|
|
steps += 1
|
|
if steps > 2000: # safety cap
|
|
break
|
|
|
|
def writePpm(self, path):
|
|
with open(path, 'wb') as f:
|
|
f.write(b'P6\n%d %d\n255\n' % (NATIVE_WIDTH, NATIVE_HEIGHT))
|
|
f.write(bytes(self.pix))
|
|
|
|
|
|
# -- main --------------------------------------------------------------------
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("db", help="path to *.json (e.g. extracted_db/FS2.1.json)")
|
|
p.add_argument("--out", default="port/screenshots/db_render.ppm")
|
|
p.add_argument("--cam-x", type=int, default=CAMERA_X_SCENERY)
|
|
p.add_argument("--cam-z", type=int, default=CAMERA_Z_SCENERY)
|
|
p.add_argument("--cam-y", type=int, default=CAMERA_Y_SCENERY)
|
|
p.add_argument("--all-sections", action="store_true",
|
|
help="walk every section, not just dispatcher-cull-passing")
|
|
args = p.parse_args()
|
|
|
|
db = json.load(open(args.db))
|
|
print(f"Loaded {len(db['sections'])} sections from {db['source']}",
|
|
file=sys.stderr)
|
|
|
|
# Find dispatcher and decide which sections to render.
|
|
disp = next(s for s in db['sections'] if s['sid'] == 0x2C)
|
|
sectionsToRender = []
|
|
if args.all_sections:
|
|
sectionsToRender = list(db['sections'])
|
|
else:
|
|
ops = sorted(disp['ops'], key=lambda o: o['pc'])
|
|
i = 0
|
|
while i < len(ops):
|
|
op = ops[i]
|
|
if op['op'] == 0x13 and i + 1 < len(ops) and ops[i+1]['op'] == 0x0D:
|
|
b = op['bytes']
|
|
bound = b[3] | (b[4] << 8)
|
|
refY = _signed16(b[5], b[6])
|
|
refX = _signed16(b[7], b[8])
|
|
if (abs(refX - args.cam_x) <= bound
|
|
and abs(refY - args.cam_z) <= bound):
|
|
sid = ops[i+1]['bytes'][1]
|
|
sec = next((s for s in db['sections'] if s['sid'] == sid), None)
|
|
if sec is not None:
|
|
sectionsToRender.append(sec)
|
|
print(f" cull pass: load sid ${sid:02X}", file=sys.stderr)
|
|
i += 2
|
|
continue
|
|
i += 1
|
|
# Always include the dispatcher so any inline polygons render too.
|
|
sectionsToRender.insert(0, disp)
|
|
|
|
print(f"Rendering {len(sectionsToRender)} sections", file=sys.stderr)
|
|
|
|
# Render.
|
|
fb = FB()
|
|
# Sky/ground fill at horizon row (= cy = VIEWPORT_BOTTOM/2).
|
|
for y in range(NATIVE_HEIGHT):
|
|
if y <= VIEWPORT_BOTTOM // 2:
|
|
fb.fillRow(y, SKY_COLOR)
|
|
elif y < VIEWPORT_BOTTOM:
|
|
fb.fillRow(y, GROUND_COLOR)
|
|
else:
|
|
fb.fillRow(y, (0, 0, 0)) # panel area
|
|
|
|
state = State()
|
|
for sec in sectionsToRender:
|
|
runSection(sec['ops'], state)
|
|
|
|
print(f"Emitted {len(state.segments)} 3D segments", file=sys.stderr)
|
|
|
|
# Project & draw.
|
|
drawn = 0
|
|
for v1x, v1y, v1z, v2x, v2y, v2z, colCode in state.segments:
|
|
clip = clipNearPlane((v1x, v1y, v1z), (v2x, v2y, v2z))
|
|
if clip is None: continue
|
|
a, b = clip
|
|
sa = project(a); sb = project(b)
|
|
if sa is None or sb is None: continue
|
|
col = COLORS.get(colCode & 0x0F, DEFAULT_LINE_COLOR)
|
|
fb.line(sa[0], sa[1], sb[0], sb[1], col)
|
|
drawn += 1
|
|
|
|
print(f"Drew {drawn} 2D segments to {args.out}", file=sys.stderr)
|
|
fb.writePpm(args.out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|