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>
213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# 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:
|
|
1. parse the payload into `{op-header, float, bit-addr, len}` instructions,
|
|
2. map each float to its plane role (edge A/B/C, z, r/g/b) by position,
|
|
3. 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` / `0x21` fields
|
|
= 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 from `scratchpad/snapv2.pkl` (cmd 735 death-cam).
|