diff --git a/emulator/firmware-decomp/cap7-geometry.json b/emulator/firmware-decomp/cap7-geometry.json new file mode 100644 index 0000000..5ded58c --- /dev/null +++ b/emulator/firmware-decomp/cap7-geometry.json @@ -0,0 +1 @@ +[[[237.5,62.0,0.931,0.262,0.131,-8.0],[235.2,46.5,0.922,0.187,0.094,-6.0],[238.0,31.0,0.933,0.278,0.139,-8.0],[236.6,18.6,0.928,0.231,0.116,-6.0]],[[240.9,43.4,0.945,0.372,0.186,-8.0],[238.9,6.2,0.937,0.307,0.154,-6.0],[237.5,31.0,0.931,0.26,0.13,-8.0],[238.0,9.3,0.933,0.278,0.139,-6.0],[237.1,62.0,0.93,0.249,0.124,-8.0],[242.2,21.7,0.95,0.415,0.208,-6.0],[238.4,37.2,0.935,0.291,0.146,-8.0],[242.3,15.5,0.95,0.417,0.209,-6.0],[237.6,31.0,0.932,0.263,0.431,-8.0],[246.5,0.0,0.966,0.554,0.377,-6.0],[242.8,31.0,0.952,0.943,0.517,-8.0],[246.9,12.4,0.968,0.957,0.685,-6.0],[241.7,40.3,0.948,0.98,0.65,-8.0],[236.3,0.0,0.927,0.936,0.678,-6.0],[253.7,62.0,0.995,0.246,0.123,-4.0],[252.1,43.4,0.988,0.324,0.162,-4.0]],[[245.1,15.5,0.961,0.384,0.192,-4.0],[239.9,40.3,0.941,0.341,0.171,-4.0],[238.5,93.0,0.935,0.446,0.223,-4.0],[234.4,62.0,0.919,0.643,0.216,-4.0],[239.1,0.0,0.938,0.645,0.424,-4.0],[231.9,0.0,0.909,0.951,0.539,-4.0],[240.7,0.0,0.944,0.984,0.685,-4.0],[233.5,40.3,0.916,0.263,0.132,-2.0],[254.6,24.8,0.998,0.415,0.208,-2.0],[243.2,0.0,0.954,0.461,0.231,-2.0],[233.8,12.4,0.917,0.431,0.215,-2.0],[243.2,15.5,0.954,0.461,0.231,-2.0],[254.3,0.0,0.997,0.614,0.207,-2.0],[250.4,9.3,0.982,0.64,0.501,-2.0],[249.6,12.4,0.979,0.94,0.58,-2.0],[245.7,0.0,0.964,0.939,0.723,-2.0]],[[126.1,62.0,0.494,0.914,0.206,0.0],[238.8,49.6,0.937,0.447,0.223,0.0],[239.1,62.0,0.938,0.448,0.224,0.0],[250.4,40.3,0.982,0.402,0.201,0.0],[234.1,31.0,0.918,0.432,0.216,0.0],[250.4,43.4,0.982,0.402,0.201,0.0],[231.3,46.5,0.907,0.639,0.37,0.0],[254.9,37.2,0.999,0.916,0.508,0.0],[252.7,34.1,0.991,0.926,0.663,0.0]]] \ No newline at end of file diff --git a/emulator/firmware-decomp/render-readout.html b/emulator/firmware-decomp/render-readout.html new file mode 100644 index 0000000..de339e9 --- /dev/null +++ b/emulator/firmware-decomp/render-readout.html @@ -0,0 +1,482 @@ +VelociRender — firmware render output + + + +
+
VWE TESLA · Division VelociRender · i860 firmware emulator
+

The board's own firmware
is rendering again

+

A cycle-faithful Intel i860 interpreter now runs the game's + actual shipped firmware — booted, initialised, and fed a complete recorded + mission over the wire. It replays every command, emits the real + PXPL5 IGC render stream, and projects the scene geometry. Below is the object + it drew, pulled straight off the emulated chip and shaded from its own vertex + normals — the first frame from this board's firmware in thirty years.

+ +
+ software-rasterised · not the hardware shader + +
the object in cap7's death-camera view — Gouraud-shaded from the + firmware's projected vertices and per-vertex normals, pulled live off the emulated i860.
+
+ +
+ +
trace · i860 execution → IGC command DMA
+
+ +
+
26,422
wire commands replayed (whole mission)
+
8,562
draw_scene frames processed
+
191/192
screen regions binned with geometry
+
0
unimplemented ops remaining on the path
+
+ +
+

01 The render signal chain

+

Nothing here is re-interpreted by us. Each stage is the firmware's own + code executing on the emulated i860 — from parsing the wire packet to writing + the bit-serial micro-program the Pixel-Planes array runs.

+
+
in
Wire
dN_receive → 42-action dispatch
+
cpu
i860
transform · clip · classify
+
geom
Binitize
per-region 64×128 bins
+
out
IGC DMA
SEND / TILE / FLUSH stream
+
px
Pixel-Planes
bit-serial array — next build
+
+
+ +
+

02 The output, decoded

+

The firmware DMAs the render to the card as an opcode stream + (DMAENGN.H). Left: a real per-region command list captured + from the emulator. Right: where each SEND lands — bit-serial IGC micro-code, not + plain floats, which is why final pixels need the array simulator.

+
+
+
+  region 0x0801f000 · DMA list
+
# addr            opcode
+0x08015800  SEND(4)      ; edge coeffs
+0x00000000  FLUSH
+0x08015820  SENDE(0x35)  ; z / colour
+0x080159e0  SEND(0x18)
+0x08015aa0  SEND(6)
+0x00000000  FLUSH
+0x08015ae0  SENDE(0x5c)
+0x08015dc0  SEND(9)      ; …binned per 64×128 tile
+
+
+
+  SEND target 0x0801f100 · IGC micro-code
+
# bit-serial Pixel-Planes program
++000  00000100   ; header
++008  46800000   ; 16384.0 scale
++01c  0610ff00  00401000
++024  0610ff08  00401081
++02c  0610ff10  00401102
++034  0610ff18  00401183
+      ; MEM += MEM, bit-serial
+      ; eval Ax+By+C across the tile
+
+
+
+ +
+

03 Where the geometry landed

+

At the frame flush the firmware walks the screen-bin array and hands the + IGC one descriptor per 64×128 region. This grid is drawn from the real captured + descriptor scan — filled cells are regions that received transformed geometry. + A full mission view fills nearly the whole frame: ground plane and sky, exactly as expected.

+
+ +
+ region has geometry + empty region (shared page) + 96 × 2 descriptor grid · 191 of 192 filled +
+
+
+ +
+

04 The object it draws — wireframe

+

One step upstream of the micro-code, the firmware transforms each model + through its matrix and writes screen-space vertices, organised as + VSTRIP triangle strips. Below is a wireframe reconstructed + live from the emulated i860 — the actual object in cap7's death-camera view, + four strips, 45 vertices, exactly as the board's firmware projected it. This is the + real geometry the Pixel-Planes array was about to shade.

+
+ +
+ strip edge + triangle cross-edge + projected vertex + 4 strips · 45 vertices · i860 output +
+
+
+ +
+

05 What it took to get here

+

The i860 core was corrected against the toolchain's own assembler output + and MAME's validated i860 model. A selection of the load-bearing fixes:

+
+
store encoding
i860 stores keep the source in bits 15:11 and split the offset — every function return went to 0 until this was found. Prologues now save r1.
+
op 0x13 escape
lock / calli / unlock share one opcode; everything decoded as calli, so every spinlock jumped to address 0. The phantom "exit stub."
+
FP pipeline
Exact PFAM/PFMAM dual-ops, per-stage precision, fdest bypass. The Newton–Raphson divide now yields 16 ÷ 16 = 1 through the pipes, unhooked.
+
pipelined pftrunc
Float→int through the adder pipe — a form no prior i860 emulator implemented. Killed an 8.3-million phantom-row loop.
+
IGC drain
Per-page completion is nonzero-on-done, and consumed pages reset their write index. Frames stopped saturating after ~1,400 draws.
+
+
+ +
+
Landed + Firmware → render command stream, end to end
+
+
+

Verified working

+
    +
  • Cold boot of the capture's own firmware build on the emulated i860
  • +
  • Full recorded mission replays to completion — 26,422 commands
  • +
  • Scene graph, transform, frustum classify on real geometry
  • +
  • Real PXPL5 IGC opcode stream emitted and decoded (SEND / TILE / FLUSH)
  • +
  • Geometry binned into 191 screen regions
  • +
  • Object wireframe reconstructed from the projected VSTRIP strips (above)
  • +
+
+
+

The last mile to pixels

+
    +
  • The wireframe is here — a shaded, textured frame is what remains
  • +
  • The SEND payloads are bit-serial Pixel-Planes micro-code, not stored triangles
  • +
  • A shaded frame needs the 128×64 pixel-processor array simulator
  • +
  • The model is known — IGCOPS.C: eval_ltree = Ax+By+C, an enable register, bit-serial MEM ops
  • +
+
+
+
+ + +
+ + diff --git a/emulator/firmware-decomp/shade_render.py b/emulator/firmware-decomp/shade_render.py new file mode 100644 index 0000000..4c03a66 --- /dev/null +++ b/emulator/firmware-decomp/shade_render.py @@ -0,0 +1,122 @@ +"""Shaded software rasteriser for geometry captured off the emulated i860. + +Input: a JSON file of triangle strips, each vertex = [screenX, screenY, nx, ny, nz, modelZ] + (as produced by capture_wireframe.py / vfull dumps). Screen xy and the per-vertex + normal come straight out of the firmware's own VSTRIP transform; modelZ is the depth. + +Output: a Gouraud-shaded PPM plus an ASCII preview. This is OUR rasteriser (a plain + barycentric fill with a z-buffer) — it is NOT the board's bit-serial Pixel-Planes + array. It exists to *show* the geometry the firmware projected, lit by the normals + the firmware computed, without needing the full IGC micro-code simulator. + +Usage: python shade_render.py geometry.json [out.ppm] +""" +import sys, json, math + +# The object recovered from cap7's death-camera frame (4 strips, 45 verts), embedded so +# the tool runs with no external capture. Regenerate by dumping vfull.pkl to this format. +DEFAULT_GEO = [ + [[237.5,62,0.931,0.262,0.131,-8],[235.2,46.5,0.922,0.187,0.094,-6],[238,31,0.933,0.278,0.139,-8],[236.6,18.6,0.928,0.231,0.116,-6]], + [[240.9,43.4,0.945,0.372,0.186,-8],[238.9,6.2,0.937,0.307,0.154,-6],[237.5,31,0.931,0.26,0.13,-8],[238,9.3,0.933,0.278,0.139,-6],[237.1,62,0.93,0.249,0.124,-8],[242.2,21.7,0.95,0.415,0.208,-6],[238.4,37.2,0.935,0.291,0.146,-8],[242.3,15.5,0.95,0.417,0.209,-6],[237.6,31,0.932,0.263,0.431,-8],[246.5,0,0.966,0.554,0.377,-6],[242.8,31,0.952,0.943,0.517,-8],[246.9,12.4,0.968,0.957,0.685,-6],[241.7,40.3,0.948,0.98,0.65,-8],[236.3,0,0.927,0.936,0.678,-6],[253.7,62,0.995,0.246,0.123,-4],[252.1,43.4,0.988,0.324,0.162,-4]], + [[245.1,15.5,0.961,0.384,0.192,-4],[239.9,40.3,0.941,0.341,0.171,-4],[238.5,93,0.935,0.446,0.223,-4],[234.4,62,0.919,0.643,0.216,-4],[239.1,0,0.938,0.645,0.424,-4],[231.9,0,0.909,0.951,0.539,-4],[240.7,0,0.944,0.984,0.685,-4],[233.5,40.3,0.916,0.263,0.132,-2],[254.6,24.8,0.998,0.415,0.208,-2],[243.2,0,0.954,0.461,0.231,-2],[233.8,12.4,0.917,0.431,0.215,-2],[243.2,15.5,0.954,0.461,0.231,-2],[254.3,0,0.997,0.614,0.207,-2],[250.4,9.3,0.982,0.64,0.501,-2],[249.6,12.4,0.979,0.94,0.58,-2],[245.7,0,0.964,0.939,0.723,-2]], + [[126.1,62,0.494,0.914,0.206,0],[238.8,49.6,0.937,0.447,0.223,0],[239.1,62,0.938,0.448,0.224,0],[250.4,40.3,0.982,0.402,0.201,0],[234.1,31,0.918,0.432,0.216,0],[250.4,43.4,0.982,0.402,0.201,0],[231.3,46.5,0.907,0.639,0.37,0],[254.9,37.2,0.999,0.916,0.508,0],[252.7,34.1,0.991,0.926,0.663,0]], +] + +LIGHT = (0.35, -0.55, 0.75) # upper-front key light +SCALE = 7 # px per screen unit +PAD = 6 # border in screen units + + +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 intensity(v): + n = _n(v[2], v[3], v[4]) + L = _n(*LIGHT) + d = n[0] * L[0] + n[1] * L[1] + n[2] * L[2] + return max(0.18, min(1.0, 0.22 + 0.85 * abs(d))) # two-sided (abs) + ambient + + +def render(geo): + tris = [] + for strip in geo: + for i in range(len(strip) - 2): + tris.append((strip[i], strip[i + 1], strip[i + 2])) + allv = [v for s in geo for v in s] + body = [v for v in allv if v[0] > 200] or allv # frame on body, ignore far outlier + mnx = min(v[0] for v in body); mxx = max(v[0] for v in body) + mny = min(v[1] for v in body); mxy = max(v[1] for v in body) + W = int(mxx - mnx) + 2 * PAD; H = int(mxy - mny) + 2 * PAD + IW, IH = W * SCALE, H * SCALE + fb = [[0.0] * IW for _ in range(IH)] + zb = [[-1e9] * IW for _ in range(IH)] + + def SX(v): return (v[0] - mnx + PAD) * SCALE + def SY(v): return (v[1] - mny + PAD) * SCALE + + for a, b, c in tris: + ax, ay, bx, by, cx, cy = SX(a), SY(a), SX(b), SY(b), SX(c), SY(c) + ia, ib, ic = intensity(a), intensity(b), intensity(c) + za, zbv, zc = a[5], b[5], c[5] + area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay) + if abs(area) < 1e-6: + continue + x0 = max(0, int(min(ax, bx, cx))); x1 = min(IW - 1, int(max(ax, bx, cx))) + y0 = max(0, int(min(ay, by, cy))); y1 = min(IH - 1, int(max(ay, by, cy))) + for py in range(y0, y1 + 1): + for px in range(x0, x1 + 1): + w0 = ((bx - px) * (cy - py) - (cx - px) * (by - py)) / area + w1 = ((cx - px) * (ay - py) - (ax - px) * (cy - py)) / area + w2 = 1 - w0 - w1 + if w0 < -0.002 or w1 < -0.002 or w2 < -0.002: + continue + z = w0 * za + w1 * zbv + w2 * zc + if z > zb[py][px]: + zb[py][px] = z + fb[py][px] = w0 * ia + w1 * ib + w2 * ic + return fb, IW, IH + + +def write_ppm(fb, IW, IH, path): + img = bytearray(IW * IH * 3) + for y in range(IH): + for x in range(IW): + v = fb[y][x] + if v > 0: + r = int(min(255, 28 + v * 168)); g = int(min(255, 50 + v * 205)); b = int(min(255, 54 + v * 122)) + else: + r, g, b = 7, 11, 17 + o = (y * IW + x) * 3 + img[o], img[o + 1], img[o + 2] = r, g, b + with open(path, 'wb') as f: + f.write(b'P6\n%d %d\n255\n' % (IW, IH)) + f.write(bytes(img)) + + +def ascii_preview(fb, IW, IH): + ramp = " .:-=+*#%@" + out = [] + for ry in range(0, IH, SCALE * 2): + line = " " + for rx in range(0, IW, SCALE): + v = fb[ry][rx] + line += ramp[min(9, int(v * 9.99))] if v > 0 else ' ' + out.append(line) + return "\n".join(out) + + +def main(): + geo = DEFAULT_GEO + if len(sys.argv) > 1 and not sys.argv[1].endswith('.ppm'): + geo = json.load(open(sys.argv[1])) + out = next((a for a in sys.argv[1:] if a.endswith('.ppm')), 'shade.ppm') + fb, IW, IH = render(geo) + print(ascii_preview(fb, IW, IH)) + write_ppm(fb, IW, IH, out) + print("\nwrote %s (%dx%d)" % (out, IW, IH)) + + +if __name__ == '__main__': + main()