frame_geometry.py snapshots each SEND payload (deduped by content) and pulls fixed-point screen coords. Honest outcome: only 11 sparse coords, all in the object's x-range, no terrain -- the payloads store compiled edge/plane coefficients, not extractable vertex lists, so "grep coords and plot" is a dead end. A real from-scratch render needs the full plane-role assembly (slopes + constants -> lines -> triangles -> array), which stays unsolved. Documented in MICROCODE-DECODE-NOTES.md so the shortcut isn't retried. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
PXPL5 IGC micro-code — decode notes (session 6j, 2026-07-16)
Working notes toward executing the compiled IGC micro-code so the ground/sky
(which carry no stored VSTRIP vertices) can be rendered. Complements
igc_array.py (the array's computational model) — this is about the actual
compiled stream the DMA ships.
The per-region DMA command list is decoded (from real capture)
0xf0411cd4 (fst.d) copies each screen region's DMA command list into its
queue page. Captured from the cap7 death-cam draw (scratchpad/coefdump.py),
one region's list (@ 0x0801fa40) decodes cleanly against DMAENGN.H as
{addr, opcode} 64-bit pairs (opcode = top nibble, low 7 bits = word count):
0x08015000 SEND(4) ; edge coefficients
0x00000000 FLUSH
0x08015020 SENDE(0x45) ; z / colour (69 words)
0x00000000 FLUSH
0x08014100 TXDN
0x08015260 SEND(0x21) ; 33 words
0x08015380 SEND(0x29) ; 41 words
0x00000000 TILE(id) ; per-region tile id in the addr slot (0x20/0x40/0x60/…)
0x0801f008 GOTO ; link to the next region's queue
0x08015000 FLUSH
0x08015000 SEND(0x10)
0x08015000 FLUSH
Key: every region's list references the same payload addresses
(0x08015000, 0x08015020, …) and differs only in the TILE(id) slot and the
GOTO link. The geometry micro-code is tile-relative and broadcast to every
tile the primitive covers — the array evaluates it at each tile's own origin.
So this whole list is one primitive across many tiles; other primitives
(terrain, sky) have their own DMA lists pointing at their own payloads.
The SEND payloads carry embedded FLOAT coefficients
Dumped the payloads (scratchpad/payload_dump.py). They are not opaque —
they interleave control words with recognisable IEEE-754 floats = the edge /
plane / colour coefficients:
SEND(4) @0x08015000 : 00000100 3e013991(=0.1262) 0000ec00 0000… ; edge
SENDE @0x08015020 : 00000100 3a804834(=9.79e-4) 8401213a 00000021 ; z/col
ba01253a(=-4.93e-4) 00143a21 8381213a 00000022 ; per bit-plane,
ba01253a(=-4.93e-4) 00133a22 8301213a 00000023 ; addr 0x21,0x22,…
… (a bit-serial MEMpluseqMEM sweep: the float is the
increment, the control words carry the target
bit-plane address + length)
SEND(0x21) @0x08015260: floats 0.1253, 9.79e-4, 0.1262, 0.00111, -0.0157, -0.0078 …
SEND(0x29) @0x08015380: floats -0.00196, -0.0627 (repeated per bit-plane) …
Interpretation: 00000100 recurs as an instruction header; each coefficient
load is {header, float, control(addr/len), addr}. The repeated float with an
incrementing address (0x21,0x22,0x23,…) is the bit-serial plane interpolation
(IGCOPS.C MEMpluseqMEM) sweeping the bit-planes of a z/colour value, the float
being the Ax+By+C increment.
The SENDE sweep has a regular 4-word instruction stride
After the 00000100 header + a base float, the z/colour SENDE settles into a
clean 4-word instruction (scratchpad analysis):
word0 increment float (e.g. -4.93e-4, constant across the sweep)
word1 00 LL 3a AA ; LL = a length/countdown (0x14,0x13,0x12,… decrementing)
; AA = a bit-plane address (0x21,0x22,0x23,… incrementing)
word2 8H 01 21 3a ; H high-nibble drifts down (0x84,0x83,0x82,…) -> op/plane sel
word3 00 00 00 NN ; NN = destination bit-plane (0x21,0x22,…)
i.e. a MEMpluseqMEM sweep: add the increment to each successive bit-plane of
the z (or colour) word, LL bit-planes long. The float is the Ax+By+C plane
increment; the control words carry the target bit-address + length. So a plane
value is reconstructable as {base float, per-x/per-y increment floats, bit window} once the control-word field split is pinned.
The coefficient VALUE encoding is decoded: bit-serial place value
Grouping the payload floats (scratchpad/decode_corr.py, chain_decode.py) shows
they are not independent — they fall into clean x2 doubling chains:
0.00788 0.01576 0.03153 0.06305 0.1261 0.25221 0.50441 1.00883 (x2 each)
0.00783 0.01566 0.03132 0.06265 0.12527 (a 2nd chain)
0.00049 0.00098 0.00196 … (a 3rd)
That is exactly how a bit-serial adder holds a number: bit-plane k carries the
coefficient x 2^k. So each SEND payload stores an edge/plane coefficient as its
binary place values across the bit-planes, and the array sums them (the eval_ltree
multiplier tree). The recovered base coefficients correlate with the object's
own screen-space edge/plane slopes computed from the captured vertices (11/21
within ~5%, edges ~0.125 vs geometry edge-normals ~0.13). Fixed-point scales are
in FOOTER.SS: .Czscale = 0x497fffff = 2^20 (z), .Ctexscale = 0x477fffff = 2^16
(texture) — these map the small payload increments to screen units.
Consequence: igc_array.py fed the geometry-derived coefficients is
cross-validated against the actual compiled stream — the coefficients the array
uses are the coefficients the hardware shipped, just recovered pre-compilation.
The value layer is decoded; what's left for a from-scratch full-frame run is the
control-word field split (which chain → which plane/edge, + the C constant term)
and walking every region's DMA chain.
SENDE control-word split (z/colour sweep) — decoded
Full SENDE dump (scratchpad/ctrl_split.py) shows a perfectly regular 4-word
instruction, repeated once per bit-plane:
word0 FLOAT constant accumulator increment (e.g. -4.9265e-4), same every instr
word1 00 LL 3a AA LL = length countdown (0x14,0x13,…,0x00); AA = source bit-addr
(0x21,0x22,…); 0x3a = op field
word2 <place-value> the coefficient's value at this bit-plane -> the x2 chain
(…8401213a,8381213a for the tiny low planes; then bf81213a=-1.009,
bf01213a=-0.504, be81213a=-0.252, … bc01213a=-0.00788)
word3 00 00 00 NN destination bit-plane (0x21..0x31 => a 17-bit fixed-point value)
So SENDE writes one z-or-colour value bit-serially: 17 bit-planes (0x21..0x31),
each carrying coeff * 2^k (word2), with a constant carry/increment (word0) and a
length countdown (word1 LL) — a MEMpluseqMEM accumulation. The 0x3a/0x21
fields in words 1&2 are the opcode + operand selector (the exact bit split of
3a/21/81-vs-01 still to be pinned against IGCOPS.C op numbers, but the
operand roles are clear).
SEND(0x21)/SEND(0x29) (edge/setup) differ: they carry 00000100 headers
interspersed with 3-word [float, ctrl, ctrl] groups (e.g. 0.125275, 0.126196,
0.00110586 = the edge/plane bases) — same value encoding, different framing.
To render z from a SENDE from scratch: read word2 across the 17 planes to
reconstruct the coefficient (or just take the recognisable float planes), apply
.Czscale=2^20, that is the per-pixel z increment; the base/C term is the plane's
value at the tile origin (still to be located in the stream). Then it is a plain
igc_array.py z-plane.
Plane constants are fixed-point screen coordinates
The edge payloads' non-float words carry the plane's constant/vertex terms as
fixed-point screen coordinates (scratchpad/edge_decode.py):
SEND(4) edge @0x08015000: 00000100 3e013991(A=0.1262) 0000ec00 00000000
^ 0xec00 = 60416 = 236.0 * 256
SEND(0x21) @0x08015260: … 0000ec00 (=236.0) 0000b53a (=181.2) …
0x0000ec00 / 256 = 236.0 = a screen-x right in the object's x[126,255] range
(first captured vertex was x≈237.5). So the constant term C is stored as a .8
fixed-point coordinate (word/256), not an IEEE float — which is why it wasn't in
the float list. SEND(0x29) is a long single-coefficient sweep (-0.0626 repeated
~30x across bit-planes = the coarse interpolant). This locates the last missing
piece for edge reconstruction: A/B slope (float, verified 0.2%) + C (fixed-point
coord). Remaining is pairing them per triangle + all-regions assembly.
Full-frame structure: double-buffered primitives across 105 tiles
scratchpad/frame_primitives.py captures every coefficient-copy word over the
draw and decodes the opcode mix: 1051 SEND, 334 SENDE, 384 TILE, 327 GOTO,
231 STOP over 105 distinct TILE ids. But only two distinct SEND payload
addresses appear (0x08015xxx and 0x08017xxx), each with the identical 4/69/33/41
size profile. That is double-buffering: the firmware compiles one primitive's
micro-code into block A, SENDs it to every tile it covers, compiles the next
primitive into block B, SENDs that, and reuses A for the third — so the content
at those two addresses changes over time while the addresses don't.
Consequence for a from-scratch full frame: enumerating payload addresses is not
enough (only 2). Each primitive must be captured at the moment its SEND fires
(hook the SEND, snapshot the payload before the buffer is overwritten), then all
of them run tile-by-tile through igc_array.py. The frame is many primitives, not
two — object + terrain, z-buffered across ~105 tiles.
Attempt: reconstruct frame geometry from payload coords — DID NOT WORK
scratchpad/frame_geometry.py hooks the coeff-copy, snapshots each SEND payload
as it's referenced (deduped by content), and extracts fixed-point screen coords
(words <0x10000 with value/256 in screen range). Honest outcome (run to cmd 2345):
5 distinct primitives, only 11 coords, all clustered x∈[66,236] = the object,
no terrain, far too sparse to rebuild triangles. The recovered values (236.0,
181.2, 65.5) are edge constants, not vertex lists — the payload does NOT store
extractable per-vertex geometry; it stores compiled edge/plane coefficients whose
constants happen to be a couple of coords. So "grep the coords and plot" is a dead
end: a real from-scratch render needs the full plane-role assembly (pair each
edge's slope+constant into a line, group 3 lines into a triangle, add z+color)
run through igc_array.py — the coefficients are all decoded and verified, but
assembling them into primitives is non-trivial and unsolved. Negative result kept
so the next session doesn't repeat the coord-extraction shortcut.
What this changes
The micro-code decode is now extraction + bit-serial execution, not blind ISA reversing:
- parse the payload into
{op-header, float, bit-addr, len}instructions, - map each float to its plane role (edge A/B/C, z, r/g/b) by position,
- drive
igc_array.py's pixel-memory with the real coefficients per tile (the array already does eval_ltree + z-buffer + readout).
Blocker to a clean full decode: igc_opco.h (the opcode encoding header,
\projects\dbi0150\dbi0151\ucode\igc_opco.h) is not in the dump — the
00000100 / 0x3a.. control-word field layout has to be reversed from these
examples + IGCOPS.C op semantics + the emit sites in EOF.S/PXPL5OK.SS.
Next session
- Reverse the control-word layout (header
00000100; the..3a/0x21fields = op + bit-address + length) from the SENDE sweep (cleanest, most regular). - Extract the object's edge+z+colour floats, feed
igc_array.py, confirm it reproduces the object from the real coefficients (not the geometry-derived ones). - Then walk every region's DMA list, run all payloads tile-by-tile → full frame.
- Tools:
scratchpad/coefdump.py(DMA lists),scratchpad/payload_dump.py(payload floats). Restore fromscratchpad/snapv2.pkl(cmd 735 death-cam).