fs2port/port/tools/sceneryRender.py
2026-05-13 21:32:05 -05:00

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