From 9d8de701f63851d28e148d8ce8d69bfeece63015 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 16 Jul 2026 15:02:59 -0500 Subject: [PATCH] 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 --- emulator/firmware-decomp/igc_array.py | 274 ++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 emulator/firmware-decomp/igc_array.py diff --git a/emulator/firmware-decomp/igc_array.py b/emulator/firmware-decomp/igc_array.py new file mode 100644 index 0000000..994d6de --- /dev/null +++ b/emulator/firmware-decomp/igc_array.py @@ -0,0 +1,274 @@ +"""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()