Files
TeslaRel410/emulator/firmware-decomp/igc_array.py
T
CydandClaude Opus 4.8 9d8de701f6 Tier 1: a faithful PXPL5 IGC Pixel-Planes array simulator
igc_array.py implements the array model from PXPL5SUP/IGCOPS.C + IGCTYPES.H:
the screen is partitioned into 64x128 tiles; every pixel owns a 26-byte
bit-addressable memory + enable bit; all pixels evaluate the same linear
tree eval_ltree(x,y,A,B,C)=(int)(x*A+y*B+C) in lockstep. A triangle is drawn
exactly as the hardware does (PXPL5GEO tri_zb_rgb): three edge trees -> the
enable register, then z + r/g/b planes interpolated per pixel, z-buffered via
MEM2geMEM2, writes gated by enable, read back out of pixel memory.

Driven by the captured 9x5 surface it lights 18/50 tiles and produces pixels
that match shade_render.py to ~1% (edge anti-aliasing only) -- validating the
array against the reference rasteriser. This is the array's computational
model, not a decode of the compiled bit-serial micro-code (that binary
encoding is still undecoded); it produces the pixels that micro-code would.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:02:59 -05:00

275 lines
11 KiB
Python

"""Tier-1: a faithful software model of the Division VelociRender PXPL5 IGC
(Pixel-Planes 5) rasteriser array, driven by geometry captured off the emulated i860.
This is the array model from sda4/DPL3/VRENDER/PXPL5SUP/IGCOPS.C + IGCTYPES.H, made
real: the screen is partitioned into 64x128 tiles (tile_x_bits=6, tile_y_bits=7); each
pixel owns a small bit-addressable memory (pxpl5_mem_chars=26 bytes) plus an enable bit.
The IGC has no per-pixel ALU program in the classic sense -- every pixel evaluates the
same linear tree in lockstep: eval_ltree(x,y,A,B,C) = (int)(x*A + y*B + C) (IGCOPS.C).
A triangle is drawn exactly as the hardware does (PXPL5GEO tri_zb_rgb):
* three EDGE trees Ei = Ai*x + Bi*y + Ci, oriented inside>=0 -> the enable register
* a Z tree and R/G/B trees, each an Ax+By+C plane through the 3 vertices
* per enabled pixel: znew = eval z-tree; if znew nearer than the stored z, the enable
survives (MEM2geMEM2) and z + rgb are written into pixel memory (writes are gated by
enable, as in hardware).
Tiles the geometry never touches are skipped (the real board bins per region first).
Finally each tile's pixel memory is read back out (read_pixmem_word) into an RGB frame.
Not a decode of the compiled bit-serial micro-code (that binary encoding is still
undecoded) -- it is the array's *computational* model, producing the pixels that
micro-code would. Validated to match shade_render.py on the same projection.
"""
import sys, struct, math, pickle, json
TILE_X_BITS, TILE_Y_BITS = 6, 7
TILE_X, TILE_Y = 1 << TILE_X_BITS, 1 << TILE_Y_BITS # 64 x 128
PXPL5_MEM_CHARS = 26
# pixel-memory bit layout we use (fits in 26 bytes = 208 bits):
Z_BIT0, Z_BITS = 0, 24 # 24-bit depth, smaller = nearer
R_BIT0, G_BIT0, B_BIT0, C_BITS = 24, 32, 40, 8
Z_FAR = (1 << Z_BITS) - 1
class Tile:
"""One 64x128 IGC tile: per-pixel enable bit + 26-byte bit memory."""
__slots__ = ('x0', 'y0', 'mem', 'enable', 'touched')
def __init__(self, x0, y0):
self.x0, self.y0 = x0, y0
self.mem = [bytearray(PXPL5_MEM_CHARS) for _ in range(TILE_X * TILE_Y)]
self.enable = bytearray(TILE_X * TILE_Y)
self.touched = False
for p in self.mem: # z-clear to far
_wword(p, Z_BIT0, Z_BITS, Z_FAR)
def _rbit(pix, bit):
return 1 & (pix[bit >> 3] >> (bit & 7))
def _wbit(pix, bit, val):
if val:
pix[bit >> 3] |= (1 << (bit & 7))
else:
pix[bit >> 3] &= ~(1 << (bit & 7))
def _rword(pix, bit0, bits):
res = 0
for i in range(bits):
res |= _rbit(pix, bit0 + i) << i
return res
def _wword(pix, bit0, bits, val):
for i in range(bits):
_wbit(pix, bit0 + i, val & 1)
val >>= 1
def plane(x0, y0, v0, x1, y1, v1, x2, y2, v2):
"""A,B,C so that A*x+B*y+C == v at each of the 3 vertices (the IGC linear tree)."""
det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
if abs(det) < 1e-9:
return 0.0, 0.0, v0
A = ((v1 - v0) * (y2 - y0) - (v2 - v0) * (y1 - y0)) / det
B = ((v2 - v0) * (x1 - x0) - (v1 - v0) * (x2 - x0)) / det
C = v0 - A * x0 - B * y0
return A, B, C
class IGCArray:
def __init__(self, W, H):
self.W, self.H = W, H
self.ntx = (W + TILE_X - 1) // TILE_X
self.nty = (H + TILE_Y - 1) // TILE_Y
self.tiles = {}
self.tris = 0
def _tile(self, tx, ty):
key = (tx, ty)
t = self.tiles.get(key)
if t is None:
t = Tile(tx * TILE_X, ty * TILE_Y)
self.tiles[key] = t
return t
def triangle(self, v0, v1, v2):
"""Each v = (sx, sy, depth, r, g, b). depth: smaller = nearer (0..1)."""
(x0, y0, z0, r0, g0, b0) = v0
(x1, y1, z1, r1, g1, b1) = v1
(x2, y2, z2, r2, g2, b2) = v2
area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
if abs(area) < 1e-6:
return
if area < 0: # orient CCW so inside>=0
x1, y1, z1, r1, g1, b1, x2, y2, z2, r2, g2, b2 = \
x2, y2, z2, r2, g2, b2, x1, y1, z1, r1, g1, b1
# three edge trees Ei = Ai*x + Bi*y + Ci; normalise each so the centroid is
# inside (>=0), independent of winding / screen-Y direction.
gcx, gcy = (x0 + x1 + x2) / 3.0, (y0 + y1 + y2) / 3.0
E = []
for (ax, ay), (bx, by) in (((x0, y0), (x1, y1)),
((x1, y1), (x2, y2)),
((x2, y2), (x0, y0))):
A = (by - ay); B = -(bx - ax); C = -(A * ax + B * ay)
if A * gcx + B * gcy + C < 0:
A, B, C = -A, -B, -C
E.append((A, B, C))
# z + rgb planes
zc = plane(x0, y0, z0, x1, y1, z1, x2, y2, z2)
rc = plane(x0, y0, r0, x1, y1, r1, x2, y2, r2)
gc = plane(x0, y0, g0, x1, y1, g1, x2, y2, g2)
bc = plane(x0, y0, b0, x1, y1, b1, x2, y2, b2)
self.tris += 1
minx = max(0, int(min(x0, x1, x2))); maxx = min(self.W - 1, int(max(x0, x1, x2)) + 1)
miny = max(0, int(min(y0, y1, y2))); maxy = min(self.H - 1, int(max(y0, y1, y2)) + 1)
for ty in range(miny // TILE_Y, maxy // TILE_Y + 1):
for tx in range(minx // TILE_X, maxx // TILE_X + 1):
self._raster_tile(self._tile(tx, ty), E, zc, rc, gc, bc,
minx, maxx, miny, maxy)
def _raster_tile(self, t, E, zc, rc, gc, bc, minx, maxx, miny, maxy):
(zA, zB, zC) = zc
lx0 = max(0, minx - t.x0); lx1 = min(TILE_X - 1, maxx - t.x0)
ly0 = max(0, miny - t.y0); ly1 = min(TILE_Y - 1, maxy - t.y0)
(A0, B0, C0), (A1, B1, C1), (A2, B2, C2) = E
for ly in range(ly0, ly1 + 1):
gy = t.y0 + ly
base = ly << TILE_X_BITS
for lx in range(lx0, lx1 + 1):
gx = t.x0 + lx
# enable = inside all three edge trees
if (A0 * gx + B0 * gy + C0) < 0: continue
if (A1 * gx + B1 * gy + C1) < 0: continue
if (A2 * gx + B2 * gy + C2) < 0: continue
idx = base + lx
pix = t.mem[idx]
znew = int((zA * gx + zB * gy + zC) * Z_FAR)
if znew < 0: znew = 0
elif znew > Z_FAR: znew = Z_FAR
if znew >= _rword(pix, Z_BIT0, Z_BITS): # MEM2geMEM2: nearer wins
continue
t.touched = True
_wword(pix, Z_BIT0, Z_BITS, znew)
rv = min(255, max(0, int(rc[0] * gx + rc[1] * gy + rc[2])))
gv = min(255, max(0, int(gc[0] * gx + gc[1] * gy + gc[2])))
bv = min(255, max(0, int(bc[0] * gx + bc[1] * gy + bc[2])))
_wword(pix, R_BIT0, C_BITS, rv)
_wword(pix, G_BIT0, C_BITS, gv)
_wword(pix, B_BIT0, C_BITS, bv)
def readout(self, bg=(7, 11, 17)):
"""Read pixel memory back out into an RGB framebuffer (row-major, RGB bytes)."""
img = bytearray(self.W * self.H * 3)
for y in range(self.H):
o = y * self.W * 3
for x in range(self.W):
img[o] = bg[0]; img[o + 1] = bg[1]; img[o + 2] = bg[2]
o += 3
for (tx, ty), t in self.tiles.items():
if not t.touched:
continue
for ly in range(TILE_Y):
gy = t.y0 + ly
if gy >= self.H: break
base = ly << TILE_X_BITS
for lx in range(TILE_X):
gx = t.x0 + lx
if gx >= self.W: break
pix = t.mem[base + lx]
if _rword(pix, Z_BIT0, Z_BITS) == Z_FAR:
continue
o = (gy * self.W + gx) * 3
img[o] = _rword(pix, R_BIT0, C_BITS)
img[o + 1] = _rword(pix, G_BIT0, C_BITS)
img[o + 2] = _rword(pix, B_BIT0, C_BITS)
return img
# ------- driver: rasterise the captured 9x5 surface through the array -------
def _n(a, b, c):
m = math.sqrt(a * a + b * b + c * c) or 1.0
return a / m, b / m, c / m
def build_from_grid(pkl, W=620, H=560, yaw=40.0, pitch=28.0):
objs = pickle.load(open(pkl, 'rb'))['objs']
allv = [v for o in objs for v in o]
xs = sorted(set(round(v['mx'], 2) for v in allv))
zs = sorted(set(round(v['mz'], 2) for v in allv))
grid = {(round(v['mx'], 2), round(v['mz'], 2)): v for v in allv}
cx = sum(xs) / len(xs); cz = sum(zs) / len(zs)
cy = sum(v['my'] for v in allv) / len(allv)
ry, rp = math.radians(yaw), math.radians(pitch)
cyw, syw, cp, sp = math.cos(ry), math.sin(ry), math.cos(rp), math.sin(rp)
def rot(x, y, z):
x, z = x * cyw + z * syw, -x * syw + z * cyw
y, z = y * cp - z * sp, y * sp + z * cp
return x, y, z
L = _n(0.35, 0.55, 0.72)
P = {}
for (x, z), v in grid.items():
X, Y, Z = rot(v['mx'] - cx, (v['my'] - cy) * 1.8, v['mz'] - cz)
nx, ny, nz = rot(v['nx'], v['ny'], v['nz']); n = _n(nx, ny, nz)
d = abs(n[0] * L[0] + n[1] * L[1] + n[2] * L[2])
it = max(0.16, min(1.0, 0.22 + 0.85 * d))
P[(x, z)] = (X, Y, Z, it)
XX = [p[0] for p in P.values()]; YY = [p[1] for p in P.values()]; ZZ = [p[2] for p in P.values()]
mnx, mxx, mny, mxy = min(XX), max(XX), min(YY), max(YY)
mnz, mxz = min(ZZ), max(ZZ)
pad = 0.1 * W
s = min((W - 2 * pad) / (mxx - mnx), (H - 2 * pad) / (mxy - mny))
ox = (W - (mxx - mnx) * s) / 2; oy = (H - (mxy - mny) * s) / 2
def sx(p): return (p[0] - mnx) * s + ox
def sy(p): return (mxy - p[1]) * s + oy
def depth(p): return (p[2] - mnz) / ((mxz - mnz) or 1) # 0 near .. 1 far
def col(it):
return (min(255, 28 + it * 168), min(255, 50 + it * 205), min(255, 54 + it * 122))
arr = IGCArray(W, H)
xsl, zsl = xs, zs
for i in range(len(xsl) - 1):
for j in range(len(zsl) - 1):
a = P[(xsl[i], zsl[j])]; b = P[(xsl[i + 1], zsl[j])]
c = P[(xsl[i], zsl[j + 1])]; d = P[(xsl[i + 1], zsl[j + 1])]
def vtx(p):
r, g, bb = col(p[3]); return (sx(p), sy(p), depth(p), r, g, bb)
arr.triangle(vtx(a), vtx(b), vtx(c))
arr.triangle(vtx(b), vtx(d), vtx(c))
return arr
def main():
S = r'C:\Users\cyd\AppData\Local\Temp\claude\c--VWE-TeslaRel410\4e848c76-6e89-4034-8047-d8d491cb32d8\scratchpad'
pkl = sys.argv[1] if len(sys.argv) > 1 else S + r'\vfull.pkl'
out = next((a for a in sys.argv[1:] if a.endswith('.ppm')), 'igc.ppm')
W, H = 620, 560
arr = build_from_grid(pkl, W, H)
active = sum(1 for t in arr.tiles.values() if t.touched)
print("IGC array: %dx%d, %d tiles (%dx%d), %d touched, %d triangles" %
(W, H, len(arr.tiles), arr.ntx, arr.nty, active, arr.tris))
img = arr.readout()
with open(out, 'wb') as f:
f.write(b'P6\n%d %d\n255\n' % (W, H)); f.write(bytes(img))
print("wrote", out)
# ASCII preview
ramp = " .:-=+*#%@"
for y in range(0, H, H // 40):
line = " "
for x in range(0, W, W // 74):
o = (y * W + x) * 3
lum = (img[o] + img[o + 1] + img[o + 2]) / 3
line += ramp[min(9, int(lum / 255 * 9.99))] if lum > 20 else ' '
print(line)
if __name__ == '__main__':
main()