From e28232e40969e96c92895a27bcbb9b60cd23ba07 Mon Sep 17 00:00:00 2001 From: Cyd Date: Tue, 14 Jul 2026 23:38:25 -0500 Subject: [PATCH] Tier 0 emulator: i860 interpreter boots VREND.MNG + runs real render code emu860.py -- an i860 interpreter (reuses the dis860 decoder) that executes the real Division firmware. This is the foundation of the preservation-grade emulator (run the board's own code, vs the GL bridge which interprets the wire). - Boots VREND.MNG cleanly: i860 init (psr/dirbase/fsr), enables the MMU, then idle-spins at 0xf0400590 waiting for a transputer interrupt (init complete). Board-config regs modelled (0xfffff720=2, 0xfffff70c=1) via map_control(). - Call harness (cpu.call): invoke firmware functions directly, bypassing the (un-emulated) transputer that normally drives the CCB. - Correct memory map: .data/.bss linked LOW (DATA_BASE=0), so globals resolve. - FP unit with double precision (register pairs): fadd/fsub/fmul/famov/frcp/ frsqr/fix/ftrunc/fgt/feq/fxfr/fiadd/fisub. - Delay slots, control regs, sparse MMIO memory with trap/logging, tail-trace and stopat debug aids. draw_scene now runs real code (incl. a double int->float conversion) until it needs accumulated scene state -- next step is replaying a captured wire command sequence through the command dispatcher (jump table @0x47cb8) so the scene builds and draw_scene emits the IGC coefficient stream (Tier-1 handoff). Co-Authored-By: Claude Fable 5 --- emulator/firmware-decomp/README.md | 72 +++++ emulator/firmware-decomp/emu860.py | 430 +++++++++++++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 emulator/firmware-decomp/emu860.py diff --git a/emulator/firmware-decomp/README.md b/emulator/firmware-decomp/README.md index f73fc7a..43b0c3e 100644 --- a/emulator/firmware-decomp/README.md +++ b/emulator/firmware-decomp/README.md @@ -155,3 +155,75 @@ case and read what it writes — the board-side fog disable (`_eof_doFOG`) and a DAC/colour-map change. That is the ground-truth answer to grayscale-squash+defog vs a palette. (Anchor utils seen: 0x2c330 = most-called util/log, 0x4408 = logger, 0xb120/0xb688 = effect helpers.) + +## Tier 0 emulator: i860 interpreter (emu860.py) — BOOTS THE FIRMWARE + +`emu860.py` executes the real `VREND.MNG` on an emulated i860 (reusing `dis860`'s +decode). First run (`python emu860.py --trace 30`): 5000 instructions, +**zero unimplemented ops**. It runs the firmware's i860 boot sequence correctly — +`psr`/`dirbase`/`fsr` setup, a board-register write at `0x8380_7000`, then it +starts **polling the CCB at `0xFFFFF7xx`** (the `CM200IO.H` `INPUT_CCB` region), +dispatching on `IO_BREAK`(1)/`IO_ACK`(2). I.e. the i860 is waiting on the +**transputer** for work — exactly the T425↔i860 model. + +Validated working: integer arith/logic/shift, loads/stores, control regs, +branches + **delay slots**, basic single-precision FP. + +**NEXT (Tier 0 cont.):** +1. Model the **CCB handshake** (`0xFFFFxxxx`: `INPUT_CCB 0xF000`/`OUTPUT_CCB + 0xF300`, `CLEAR_INT`, the `IO_*` opcodes) so the firmware advances past the + poll loop into its main command loop. Stub the transputer side: feed a + captured wire in, ACK the CCB. +2. Model the **board register region** (`0x8380_xxxx`) as it's touched. +3. Add remaining instructions as the geometry path needs them (dual-instruction + mode, the pipelined `pf*`/graphics FP ops). +4. Capture the **IGC coefficient stream** the firmware emits (Tier 1 handoff). + +Then Tier 1 = rasterize the captured coefficients; Tier 2 = the T425 + timing. + +### Tier 0 update: full boot mapped → idle (waiting for transputer interrupt) + +With `map_control()` feeding the board-config regs (`0xfffff720=2`, +`0xfffff70c=1`), the firmware runs its whole low-level boot in ~73 instructions: +clear the control-register block, write POST-style status codes, **enable the +MMU** (`dirbase` ATE), then **idle-spin at `0xf0400590` (`bri r1`→self)**. That's +init-complete — a self-spin breaks only on an **interrupt**, so the i860 is +waiting for the transputer to hand it CCB commands. The whole application runs in +the interrupt handler, not the boot path. + +So the next phase is the **command-processing path**: +1. i860 **interrupt/trap** (INT → PC←`0xFFFFFF00`) + the **MMU** (ATE is now on; + try identity-map first). +2. The **CCB** struct + a **transputer stub** that writes a command and asserts + the interrupt. +3. Inject a captured wire command → the handler dispatches (jump table @0x47cb8) + → runs → emits IGC coefficients (Tier-1 handoff). + +emu860 debug aids added: `map_control()`, tail ring-buffer, `stopat`. + +### Tier 0 update: call harness + real globals + FP core (draw_scene runs real code) + +Autonomous session progress on emu860.py: +- **Call harness** (`cpu.call(addr, args)`): invoke firmware functions directly + (PGI conv: args r16.., ret r16, r1=return, r2=sp), bypassing the un-emulated + transputer. Validated on `velocirender_statistics`. +- **Data placement FIXED:** `.data`/`.bss` are linked LOW (code builds global + addrs 0xebc4..0x22644). `DATA_BASE=0x0` (verified: globals now carry real data, + e.g. `[0xebc4]` is ASCII, `[0x10]=0xffffffff`). Was the reason cold calls saw + zero globals. +- **FP unit rewritten with DOUBLE precision** (register pairs f[N]=low/f[N+1]=hi): + fadd/fsub/fmul/famov/frcp/frsqr/fix/ftrunc/fgt/feq/fxfr/fiadd/fisub, precision + from bits 7/8. (pf* pipelined ops treated non-pipelined for now.) +- **Result:** `draw_scene` now executes real code (299 instrs incl. a double + int→float conversion helper) until it needs SCENE STATE — cold-calling it on an + empty scene follows uninitialised scene pointers and derails. Confirms a frame + needs the full wire sequence replayed first. + +**NEXT (the command-replay phase):** find the top-level command handler +(`velocirender_input`/`remote_velocirender`; dispatcher head ~`0xf040eeb0`, jump +table @file 0x47cb8), feed a **captured wire command sequence** (create/dcs/ +list_add → draw_scene) via a CCB struct at `0xffffe000`, so the scene builds and +`draw_scene` renders → capture the IGC coefficient stream (Tier-1 handoff). +Boot config regs: `map_control()` sets `0xfffff720=2`, `0xfffff70c=1`. Note the +transputer set up the MMU/page-tables + trap vector, so the natural interrupt +boot-flow needs the transputer OR the direct-call harness (used here). diff --git a/emulator/firmware-decomp/emu860.py b/emulator/firmware-decomp/emu860.py new file mode 100644 index 0000000..88eef96 --- /dev/null +++ b/emulator/firmware-decomp/emu860.py @@ -0,0 +1,430 @@ +"""Intel i860 interpreter -- Tier 0 of the VelociRender emulator. + +Goal: execute the real firmware (VREND.MNG) so the board's own code produces the +render, rather than us reinterpreting the wire. Reuses the validated decoder +(dis860) for tracing; execution semantics are implemented here. + +Status: FOUNDATION. Core integer / load-store / branch / basic FP, delay slots, +sparse memory with MMIO traps, VREND.MNG loader. Run it to see how far the +firmware gets and which peripherals/instructions it needs next. + + python emu860.py [--trace N] [--steps N] +""" +import sys, os, struct +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import dis860 + +PAGE = 1 << 16 +MASK32 = 0xFFFFFFFF + +def s16(v): return v - 0x10000 if v & 0x8000 else v +def s26(v): return v - 0x4000000 if v & 0x2000000 else v +def u32(v): return v & MASK32 +def i32(v): + v &= MASK32 + return v - (1 << 32) if v & 0x80000000 else v + + +class Mem: + """Sparse page memory. RAM pages autocreate on write; reads of unmapped RAM + return 0 (logged once). MMIO ranges dispatch to callbacks.""" + def __init__(self, log): + self.pages = {} + self.mmio = [] # (lo, hi, read_cb, write_cb, name) + self.log = log + self._warned = set() + + def map_mmio(self, lo, hi, rd, wr, name): + self.mmio.append((lo, hi, rd, wr, name)) + + def _page(self, addr, create): + pn = addr >> 16 + p = self.pages.get(pn) + if p is None and create: + p = self.pages[pn] = bytearray(PAGE) + return p + + def load_blob(self, addr, data): + for i, b in enumerate(data): + p = self._page(addr + i, True) + p[(addr + i) & 0xFFFF] = b + + def _mmio(self, addr): + for lo, hi, rd, wr, name in self.mmio: + if lo <= addr < hi: + return (rd, wr, name) + return None + + def r32(self, addr): + addr = u32(addr) + m = self._mmio(addr) + if m: return u32(m[0](addr)) + p = self._page(addr, False) + if p is None: + if (addr >> 16) not in self._warned: + self._warned.add(addr >> 16) + self.log(f" [mem] read unmapped {addr:#010x} -> 0") + return 0 + return struct.unpack_from('= 2: self.f[i] = u32(v) + + # single-precision float helpers + @staticmethod + def f2b(x): return struct.unpack('> 2) & 1 + + # ------------- one instruction ------------- + def step(self): + pc = self.pc + if pc in self.stopat: + self.stop = True; self.stopmsg = f"stopat {pc:#010x}"; return False + w = self.mem.r32(pc) + if self.tailn: + self.tail.append((pc, w)) + if len(self.tail) > self.tailn: self.tail.pop(0) + if self.trace and self.steps < self.trace: + m, ops = dis860.decode(w, pc) + self.log(f"{pc:#010x}: {w:08x} {m:<10} {ops}") + self._branch = None + self.execute(w, pc) + self.steps += 1 + if self.stop: + return False + if self._branch is not None: + target, delayed = self._branch + if delayed: + # execute one delay-slot instruction, then jump + ds = pc + 4 + w2 = self.mem.r32(ds) + if self.trace and self.steps < self.trace: + m, ops = dis860.decode(w2, ds) + self.log(f"{ds:#010x}: {w2:08x} {m:<10} {ops} ; [delay slot]") + self._branch = None + self.execute(w2, ds) + self.steps += 1 + self.pc = self._branch[0] if self._branch else target + else: + self.pc = target + else: + self.pc = pc + 4 + return not self.stop + + def branch(self, target, delayed=True): + self._branch = (u32(target), delayed) + + def execute(self, w, pc): + op = (w >> 26) & 0x3f + src2 = (w >> 21) & 0x1f + dest = (w >> 16) & 0x1f + src1 = (w >> 11) & 0x1f + imm = w & 0xffff + + # ---- loads / stores ---- + if op in (0x04, 0x05): # ld.l off(src2),dest + off = s16(imm & 0xfffc) + self.wr(dest, self.mem.r32(self.rd(src2) + off)); return + if op in (0x06, 0x07): # st.l dest? -> src? : st.l r,off(src2) + off = s16(imm & 0xfffc) + # st.l isrc1(dest field is src), src2 base : encoding stores rdest-field + self.mem.w32(self.rd(src2) + off, self.rd(dest)); return + if op in (0x00, 0x01): # ld.s (16-bit) -- sign? treat unsigned + off = s16(imm & 0xfffe) + self.wr(dest, self.mem.r16(self.rd(src2) + off)); return + if op in (0x08, 0x09): # fld.l off(src2),fdest + off = s16(imm & 0xfff8 if op == 0x09 else imm) + self.fwr(dest, self.mem.r32(self.rd(src2) + off)); return + if op in (0x0a, 0x0b): # fst.l fdest,off(src2) + off = s16(imm & 0xfff8 if op == 0x0b else imm) + self.mem.w32(self.rd(src2) + off, self.frd(dest)); return + if op == 0x0c: # ld.c ctrl,dest + self.wr(dest, self.cr.get(src2, 0)); return + if op == 0x0e: # st.c src1,ctrl + self.cr[src2] = self.rd(src1); return + if op == 0x02: # ixfr src1 -> fdest (int->FP reg move) + self.fwr(dest, self.rd(src1)); return + + # ---- control transfer ---- + if op == 0x10: # bri src1 (indirect, delayed) + self.branch(self.rd(src1)); return + if op == 0x13: # calli src1 + self.wr(1, pc + 8); self.branch(self.rd(src1)); return + if op == 0x1a: # br + self.branch(pc + 4 + s26(w & 0x03ffffff) * 4); return + if op == 0x1b: # call + self.wr(1, pc + 8); self.branch(pc + 4 + s26(w & 0x03ffffff) * 4); return + if op in (0x1c, 0x1d): # bc / bc.t (branch if CC) + tgt = pc + 4 + s26(w & 0x03ffffff) * 4 + if self.cc(): self.branch(tgt, delayed=(op == 0x1d)) + return + if op in (0x1e, 0x1f): # bnc / bnc.t (branch if !CC) + tgt = pc + 4 + s26(w & 0x03ffffff) * 4 + if not self.cc(): self.branch(tgt, delayed=(op == 0x1f)) + return + if op in (0x14, 0x15, 0x16, 0x17): # btne / bte (compare & branch) + broff = s16(((dest << 11) | (w & 0x7ff)) & 0xffff) + a = src1 if (op & 1) else self.rd(src1) # even = 5-bit const + b = self.rd(src2) + take = (a != b) if op < 0x16 else (a == b) + if take: self.branch(pc + 4 + broff * 4, delayed=False) + return + + # ---- arithmetic / logic (odd opcode = 16-bit immediate) ---- + base = op & 0x3e + if base in (0x20, 0x22, 0x24, 0x26): # addu subu adds subs + b = self.rd(src2) + a = s16(imm) if (op & 1) else self.rd(src1) + if base == 0x20: r = a + b # addu + elif base == 0x22: r = b - a # subu + elif base == 0x24: r = i32(a) + i32(b) # adds + else: r = i32(b) - i32(a) # subs + self.wr(dest, r) + # i860 sets CC on add/sub (borrow/carry); approximate signed compare use + self.set_cc((r & MASK32) == 0 if False else (i32(b) - i32(a)) >= 0 if base in (0x22, 0x26) else True) + return + if base in (0x28, 0x2a, 0x2c, 0x2e): # shl shr shrd shra + cnt = (s16(imm) & 0x1f) if (op & 1) else (self.rd(src1) & 0x1f) + b = self.rd(src2) + if base == 0x28: r = b << cnt # shl + elif base == 0x2a: r = b >> cnt # shr (logical) + elif base == 0x2e: r = i32(b) >> cnt # shra + else: r = b >> cnt # shrd (approx) + self.wr(dest, r); return + if base in (0x30, 0x32, 0x34, 0x36, 0x38, 0x3a, 0x3c, 0x3e): # and..xorh + a = imm if (op & 1) else self.rd(src1) + hi = base in (0x32, 0x36, 0x3a, 0x3e) + if hi and (op & 1): a = imm << 16 + b = self.rd(src2) + g = base & 0x3c + if g == 0x30: r = b & a # and/andh + elif g == 0x34: r = b & ~a # andnot/andnoth + elif g == 0x38: r = b | a # or/orh + else: r = b ^ a # xor/xorh + self.wr(dest, r) + self.set_cc((r & MASK32) == 0) # logicals set CC = (result==0) + return + + # ---- FP unit (opcode 0x12) ---- + if op == 0x12: + self.exec_fp(w, src1, src2, dest); return + + # ---- unhandled ---- + m, ops = dis860.decode(w, pc) + self.stop = True + self.stopmsg = f"unimplemented op {op:#04x} ({m} {ops}) @ {pc:#010x} w={w:#010x}" + + # precision-aware FP register access (doubles occupy register PAIRS: + # f[N]=low32, f[N+1]=high32, little-endian) + def rdf(self, reg, dbl): + if dbl: + b = reg & ~1 + return struct.unpack('> 7) & 1 # source precision (1 = double) + rp = (w >> 8) & 1 # result precision + # NOTE: pf* pipelined ops are treated as non-pipelined (functional) for now. + if sub == 0x20: # fmul + self.wrf(dest, self.rdf(src1, sp) * self.rdf(src2, sp), rp) + elif sub == 0x30: # fadd + self.wrf(dest, self.rdf(src1, sp) + self.rdf(src2, sp), rp) + elif sub == 0x31: # fsub + self.wrf(dest, self.rdf(src1, sp) - self.rdf(src2, sp), rp) + elif sub == 0x33: # famov (move src1, precision-convert) + self.wrf(dest, self.rdf(src1, sp), rp) + elif sub == 0x22: # frcp (reciprocal of src2) + b = self.rdf(src2, sp) + self.wrf(dest, (1.0 / b) if b else 0.0, rp) + elif sub == 0x23: # frsqr (recip sqrt of src2) + import math + b = self.rdf(src2, sp) + self.wrf(dest, (1.0 / math.sqrt(b)) if b > 0 else 0.0, rp) + elif sub in (0x32, 0x3a): # fix (round) / ftrunc -> int in FP reg + a = self.rdf(src1, sp) + iv = int(a) if sub == 0x3a else int(a + (0.5 if a >= 0 else -0.5)) + self.fwr(dest & ~1 if rp else dest, iv & 0xFFFFFFFF) + elif sub == 0x34: # fgt: CC = src1 > src2 + self.set_cc(self.rdf(src1, sp) > self.rdf(src2, sp)) + elif sub == 0x35: # feq: CC = src1 == src2 + self.set_cc(self.rdf(src1, sp) == self.rdf(src2, sp)) + elif sub == 0x40: # fxfr FP->int + self.wr(dest, self.frd(src1)) + elif sub == 0x49: # fiadd (integer add in FP unit, 32-bit) + self.fwr(dest, self.frd(src1) + self.frd(src2)) + elif sub == 0x4d: # fisub + self.fwr(dest, self.frd(src1) - self.frd(src2)) + elif sub == 0x5f: # fnop + pass + else: + self.stop = True + self.stopmsg = f"unimplemented FP sub {sub:#04x} @ {self.pc:#010x}" + + # ------------- board control / CCB region (0xFFFFxxxx) ------------- + def map_control(self, cfg=None): + """Model the transputer<->i860 control/config + CCB region. + cfg: {addr: value} overrides for the 0xFFFFF7xx config registers.""" + self.ctrl = {0xfffff720: 2, # must read 2 (IO_ACK) for normal boot + 0xfffff70c: 1} # board-config select (1 = CCB @0xffffe000 path) + if cfg: self.ctrl.update(cfg) + self.ctrl_log = [] + def rd(a): + v = self.ctrl.get(a, 0) + self.ctrl_log.append(('r', a, v)) + return v + def wr(a, v): + self.ctrl[a] = v + self.ctrl_log.append(('w', a, v)) + self.mem.map_mmio(0xfff00000, 0x100000000, rd, wr, 'ctrl/ccb') + + # ------------- board / IGC MMIO (logged) ------------- + def map_board(self): + """Log accesses to the board-register (0x8380_xxxx) and IGC/DMA regions, + and capture the IGC coefficient stream (Tier-1 handoff).""" + self.board_log = [] + self.igc = [] # captured (addr,val) IGC coefficient writes + def brd_rd(a): self.board_log.append(('r', a, 0)); return 0 + def brd_wr(a, v): self.board_log.append(('w', a, v)) + self.mem.map_mmio(0x83000000, 0x84000000, brd_rd, brd_wr, 'board') + + # ------------- call harness (direct handler invocation) ------------- + RET_SENTINEL = 0xbadca110 + def call(self, addr, args=(), sp=0x000c0000, maxsteps=2_000_000): + """Invoke a firmware function directly (PGI conv: args r16.., ret r16, + r1=return, r2=sp). Runs until it returns to the sentinel. Returns r16.""" + self.wr(1, self.RET_SENTINEL) + self.wr(2, sp) + for i, a in enumerate(args): + self.wr(16 + i, a) + self.pc = u32(addr) + self.stop = False; self.stopmsg = '' + n0 = self.steps + while self.steps - n0 < maxsteps: + if self.pc == self.RET_SENTINEL: + return self.rd(16) + if not self.step(): + return None # faulted (stopmsg set) + self.stop = True; self.stopmsg = f"call {addr:#x} ran {maxsteps} steps (no return)" + return None + + # ------------- loader ------------- + def load_mng(self, path, base=0xf0400000): + d = open(path, 'rb').read() + tsize, dsize, bsize = struct.unpack_from('