Files
TeslaRel410/emulator/firmware-decomp/emu_main.py
T
CydandClaude Opus 4.8 a688cc1c30 i860 emu: ISA round 2 (ground-truth encodings) + authentic-main runner; firmware
processes the full wire boot incl. the downloaded PAZ/sfx module

ISA fixes, all derived from the toolchain's own .S<->.O pairs (AS860.ZIP:
OPTFLOAT/TRISTRIP/ZBUF32, plus DNC.O) and the firmware's linked COFF header:

- DATA_BASE = 0x1000 DEFINITIVE: VREND.MNG carries its original COFF header in
  the file tail (.data vaddr 0x1000, .bss 0x1f940, entry 0xf0400000).
- Integer loads: even opcodes are register-indexed (EA = src2 + src1); op 4/5
  size flag = instr bit0 (0 = ld.s 16-bit, 1 = ld.l 32-bit); ld.b/ld.s
  sign-extend.
- Integer stores: st.s/st.l selected by offset bit0, same split-offset rule.
- FP loads/stores: FP register lives in the DEST field for both fld and fst
  (fst does NOT use the integer split-store encoding); flag bits: bit0 =
  auto-increment (base <- EA), bit1 1=.l/0=.d, bit2 = .q; .d/.q span register
  pairs/quads. ~450 fld.d + ~300 fst.d were previously read/written 32-bit.
- bla (op 0x2d, was misdecoded as shrd): branch-on-LCC-and-add with the
  sign-dependent LCC rule (src1<0 -> signed sum >= 0), so spent countdown
  loops terminate. 335 bla instructions in the firmware.
- CORE ESCAPE (op 0x13): sub-op 1 = lock, 2 = calli, 7 = unlock. Previously
  everything decoded as calli, so every spinlock acquire jumped to address 0 --
  this was the phantom "exit stub" behind most earlier derails.
- f2b: IEEE overflow -> +/-inf instead of raising.

emu_main.py (new): runs the firmware's OWN main() (0xf0403f10) and feeds real
wire captures through a hooked dN_receive, so init/do_init/dispatch/handlers
all execute authentically. Provides the transputer-monitor environment
(processor id, DRAM region descriptors in the shared control block, sbrk/
shared-block seed slots) and hooks only the link primitives (bla busy-wait,
dN_mynode/dN_nodes/dN_receive/dN_send, putchar path, spinlocks, page allocator
+ virt->phys translator pending Tier-2 VRENDMON).

KEY DISCOVERY: the capture's args860/code860/data860/bss860 preamble is the
host DOWNLOADING an additional i860 module (the PAZ/sfx renderer layer, banner
"i860 50MHz") which installs the runtime handler tables and system objects.
Feeding it through the firmware's own handlers, the module loads and makes the
first IGC board-register writes. State: 834+ wire commands processed (module
download + init + create); first draw_scene sits at command 1568.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:26:10 -05:00

239 lines
11 KiB
Python

"""Run VREND.MNG's own main() and feed it real wire captures through dN_receive.
This replaces the per-handler harness (emu_replay) with the AUTHENTIC path: the
firmware's main() does its own startup (newBytes pools, dN_mynode/dN_nodes) and
enters remote_velocirender()'s while(1) { dN_receive(); dispatch; } loop. We only
hook the transputer-LINK primitives (they'd block on real hardware); everything
else -- init, do_init, dispatch, handlers, allocator -- is the firmware's own code.
Firmware addresses (source-matched disassembly, VREND.C + VR_REMOT.C):
main 0xf0403f10 (processor-0 path; _processorId in .data)
bla 0xf04285f0 busy-wait (also tail-called with manual r1)
dN_mynode 0xf0402570 *arg = my node id -> 2 (replying node)
dN_nodes 0xf0402610 *arg = total nodes -> 3 (1 view)
dN_receive 0xf0402450 (&client,&bytes,buf,flag) <- wire frames
remote_velocirender 0xf040eea0
velocirender_init 0xf040e300 (called by the loop on action 0)
do_init 0xf040afb0 (deferred heavy init)
Usage: python emu_main.py [capture.raw.bin] [--max-cmds N] [--verbose]
"""
import sys, os, struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, r'C:\VWE\TeslaRel410\dpl3-revive\patha')
import emu860
from emu_replay import parse_capture, MNG, BOOT_ACTIONS
from vrboard import A
MAIN = 0xf0403f10
BLA = 0xf04285f0
DN_MYNODE = 0xf0402570
DN_NODES = 0xf0402610
DN_RECEIVE = 0xf0402450
V_INIT = 0xf040e300
DO_INIT = 0xf040afb0
ANAME = {int(a): a.name for a in A}
class MainRunner:
def __init__(self, cap, verbose=False, max_cmds=None):
self.cpu = emu860.I860(trace=0)
self.cpu.load_mng(MNG)
self.cpu.map_control(); self.cpu.map_board()
# Transputer-boot environment: the monitor pokes the processor id into the
# first .data word before starting the app (file ships garbage there).
# main() reads [0x1000] (_processorId) and spins the processor-1 path if != 0.
self.cpu.mem.w32(0x1000, 0)
# Monitor-provided MEMORY environment (we are the transputer/monitor).
# The page allocator (0xf04350b8) reads region descriptors from the shared
# control block: [0x23ae8]=0xfffff800 (DRAM bank 0 @0x08000000) and
# [0x23c84]=0xfffff80c (bank 1 @0x0C000000) -- set by 0xf0434e44, populated
# by the monitor. Layout: desc={+0: first extent, +8: free bytes};
# extent (in DRAM) ={+0: next extent, +8: extent size}.
RAM0, RAM1, RSIZE = 0x08000000, 0x0C000000, 0x01000000 # 16MB per bank
for desc, base in ((0xfffff800, RAM0), (0xfffff80c, RAM1)):
self.cpu.mem.w32(desc + 0, base)
self.cpu.mem.w32(desc + 4, 0)
self.cpu.mem.w32(desc + 8, RSIZE)
self.cpu.mem.w32(base + 0, 0)
self.cpu.mem.w32(base + 4, 0)
self.cpu.mem.w32(base + 8, RSIZE)
# The bss pointers to those descriptors are normally stored by the meminit
# routine 0xf0434df4 (monitor start path we don't run) -- store them ourselves.
self.cpu.mem.w32(0x23ae8, 0xfffff800)
self.cpu.mem.w32(0x23c84, 0xfffff80c)
# malloc's sbrk break pointers at [0x1ecc0]/[0x1ecc4]: file ships garbage
# (startup-written slots, like _processorId). Zero => sbrk lazy-inits itself
# through the (hooked) page allocator.
self.cpu.mem.w32(0x1ecc0, 0)
self.cpu.mem.w32(0x1ecc4, 0)
# Shared "uncached block" pointers at [0x14fc4]/[0x14fc8]/[0x14fcc]
# (transputer-visible communication blocks with embedded spinlocks at +0x18,
# used by the link/console paths). Monitor-provided; point them at fresh
# zeroed RAM so the locks start released.
self.cpu.mem.w32(0x14fc4, 0x00300000)
self.cpu.mem.w32(0x14fc8, 0x00301000)
self.cpu.mem.w32(0x14fcc, 0x00302000)
# Page-directory base used by the allocator's VM bookkeeping ([0x22590]);
# tables live at 0x76000000 on the real board. We don't translate (flat
# memory), the firmware just needs somewhere writable to keep them.
self.cpu.mem.w32(0x22590, 0x76000000)
# Feed EVERYTHING through the firmware, including the 860-boot actions:
# cap7 starts with args860/code860/data860/bss860 -- the host DOWNLOADS an
# extra i860 module (the PAZ/sfx layer) that installs the runtime handler
# tables and system objects. Skipping them leaves those tables empty.
# (hspcode is transputer-side; keep it out.)
cmds = [(a, p) for a, p in parse_capture(cap) if a != A.hspcode]
if max_cmds: cmds = cmds[:max_cmds]
self.queue = cmds
self.qi = 0
self.verbose = verbose
self.done = {}
self.stubbed = {} # discovered link primitives -> hit count
# Page allocation: the firmware's own allocator (0xf04350b8) maintains the
# board's two-level VM page tables, which the MONITOR bootstraps (pre-mapped
# DRAM PTEs at 0x76000000, phys->virt bias at [0x23ca0]) -- replicating that
# faithfully is Tier-2 (run VRENDMON on an emulated T425). Until then we hook
# the allocator core with a bump allocator over the DRAM banks; callers are:
# r16 = region selector (-1 = any, 0 = bank0, 1 = bank1), r17 = page count.
# Node addresses are opaque to the scene/render logic, so the IGC stream is
# unaffected by allocator layout.
self.heap = [0x08010000, 0x0c010000] # small offset: skip extent headers
self.hooks = {
BLA: self.h_ret,
DN_MYNODE: lambda c: self.h_writeback(c, 2),
DN_NODES: lambda c: self.h_writeback(c, 3),
DN_RECEIVE: self.h_receive,
0xf04350b8: self.h_allocpages,
# virt->phys page translator (walks the monitor's page tables, which we
# don't build): flat memory => identity. Returns the PTE for r16's page.
0xf0434d90: lambda c: self.h_ret(c, c.rd(16) & ~0xfff),
# reply path (replying = me==2): 0xf040ae90 = reply wrapper (tail-called
# from the loop with r1 = loop top), 0xf0402370 = dN_send-style link
# primitive. We log the reply value and return.
0xf040ae90: self.h_reply,
0xf0402370: self.h_ret,
# outgoing-message writer (same shared_cntl+0x18 + mutex shape as the
# send primitive; do_init calls it with message-type words like 0x30
# whose type-nibble has no local jump-table entry). Log + drop.
0xf04030c8: self.h_outmsg,
# Spinlock acquire/release (i860 lock/unlock bus primitives guarding
# shared blocks). Single-threaded emulation: no-op both, so a skipped
# release inside a hooked path can never deadlock us.
0xf042bdf0: self.h_ret,
0xf042be48: self.h_ret,
# Dispatch-tail chain (create's table sends types 2-5 to 0xf040bd0c =
# the `or 0xece8,r31,r31` inside new_node's dispatch, reusing its
# per-type init table). The `or` needs r31=0 on entry; a jump arrives
# with r31 = the entry address itself. Zero it and execute normally.
0xf040bd0c: self.h_chainfix,
}
self.replies = []
self.outmsgs = []
def h_chainfix(self, cpu):
if cpu.rd(31) == cpu.pc: # arrived via a table jump (r31 = entry addr)
idx4 = cpu.rd(30) # (type-2)*4, set by the dispatching code
tgt = cpu.mem.r32(0xece8 + idx4)
cpu.wr(31, 0)
# entry pointing back at the chain = "no per-type init" -> common tail
cpu.pc = 0xf040bd1c if tgt == 0xf040bd0c else tgt
return True
cpu.step() # normal fall-through execution of the `or`
return True
def h_outmsg(self, cpu):
self.outmsgs.append((cpu.rd(16), cpu.rd(17)))
return self.h_ret(cpu)
def h_reply(self, cpu):
self.replies.append(cpu.rd(16))
return self.h_ret(cpu)
def h_allocpages(self, cpu):
sel = cpu.rd(16)
bank = 1 if sel == 1 else 0
size = (cpu.rd(17) & 0xffff) << 12
ptr = self.heap[bank]
self.heap[bank] += size
return self.h_ret(cpu, ptr)
# ---- hook helpers: emulate a called function returning ----
def h_ret(self, cpu, ret=None):
if ret is not None: cpu.wr(16, ret)
cpu.pc = cpu.rd(1)
return True
def h_writeback(self, cpu, val):
cpu.mem.w32(cpu.rd(16), val)
return self.h_ret(cpu)
def h_receive(self, cpu):
if self.qi >= len(self.queue):
return 'done'
act, pl = self.queue[self.qi]; self.qi += 1
buf = cpu.rd(18)
cpu.mem.w32(buf, act)
cpu.mem.load_blob(buf + 4, pl or b'')
cpu.mem.w32(cpu.rd(16), 0) # *client = 0
cpu.mem.w32(cpu.rd(17), 4 + len(pl)) # *receive_bytes
n = ANAME.get(act, act)
self.done[n] = self.done.get(n, 0) + 1
if self.verbose or self.qi % 2000 == 0 or self.qi <= 20:
print(f" [{self.qi:5}/{len(self.queue)}] {n:<22} len={len(pl):6} "
f"steps={cpu.steps:>12,} igc={len(cpu.igc)}")
return self.h_ret(cpu)
def run(self, budget=2_000_000_000):
cpu = self.cpu
cpu.wr(1, cpu.RET_SENTINEL); cpu.wr(2, 0x000c0000)
cpu.wr(16, 0); cpu.wr(17, 0) # argc, argv
cpu.pc = MAIN
watch = {V_INIT: 'velocirender_init', DO_INIT: 'do_init'}
while cpu.steps < budget:
pc = cpu.pc
if pc == cpu.RET_SENTINEL:
print(f"main RETURNED after {cpu.steps:,} steps"); break
h = self.hooks.get(pc)
if h:
r = h(cpu)
if r == 'done':
print(f"\ncapture exhausted after {self.qi} commands, "
f"{cpu.steps:,} steps"); break
continue
if pc in watch:
print(f" >> {watch.pop(pc)} entered (steps={cpu.steps:,})")
if not cpu.step():
print(f"\nSTOP: {cpu.stopmsg}")
import dis860
for tpc, tw in cpu.tail[-12:]:
m, ops = dis860.decode(tw, tpc)
print(f" {tpc:#010x}: {tw:08x} {m} {ops}")
break
return cpu
def main():
args = [a for a in sys.argv[1:] if not a.startswith('--')]
cap = args[0] if args else r'C:\VWE\TeslaRel410\dpl3-revive\patha\cap7.raw.bin'
verbose = '--verbose' in sys.argv
mc = None
if '--max-cmds' in sys.argv:
mc = int(sys.argv[sys.argv.index('--max-cmds') + 1])
r = MainRunner(cap, verbose=verbose, max_cmds=mc)
print(f"{os.path.basename(cap)}: {len(r.queue)} wire commands queued")
cpu = r.run()
print(f"\nreplayed: {r.done}")
print(f"IGC writes: {len(cpu.igc)} board-log entries: {len(cpu.board_log)}")
if cpu.igc:
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'igc_stream.bin')
with open(out, 'wb') as f:
for addr, val in cpu.igc:
f.write(struct.pack('<II', addr, val))
print(f"IGC stream -> {out}")
if __name__ == '__main__':
main()