Skip to main content

logicaffeine_compile/codegen_sva/
soft_core_model.rs

1//! The cycle-accurate Rust model of `benchmarks/silicon/soft_core.v` — the executable spec of the
2//! LOGOS universal interpreter in silicon. Fetch/decode/execute over a chip-word program
3//! (`[opcode:8][dst:8][a:8][b:8]`; wide/float/heap ops per the doc on [`run_soft_core_typed`]),
4//! returning the `Show` sequence. This is the differential ORACLE the whole spatial-interpreter
5//! campaign checks against: `compile_to_full_words` -> [`run_soft_core_typed`] must equal
6//! `compile::vm_outcome` for every program, and the iverilog testbenches agree with it. Lifted here
7//! (from `rtl.rs`'s test module) so both the in-crate RTL tests and the `logicaffeine_tests`
8//! integration locks share ONE oracle.
9
10/// The Rust model of `alu.v` (32-bit), mirroring `emit_alu_datapath_verilog` exactly (wrapping /
11/// signed). Div / ExactDiv / Mod / FloorDiv are NOT here — they route to [`soft_core_divider`]
12/// (mirroring the soft-core's `shared_divider`). DivPow2 stays (a cheap arithmetic shift).
13pub fn soft_core_alu(opcode: u8, a: i32, b: i32) -> i32 {
14    match opcode {
15        8 | 9 => a.wrapping_add(b), // Add / AddAssign
16        10 => a.wrapping_sub(b),    // Sub
17        11 => a.wrapping_mul(b),    // Mul
18        16 => {
19            // DivPow2 (signed ÷2^b, toward zero)
20            let k = (b & 31) as u32;
21            let bias = if a < 0 {
22                (1u32 << k).wrapping_sub(1) as i32
23            } else {
24                0
25            };
26            a.wrapping_add(bias) >> k
27        }
28        19 => a ^ b,
29        20 => a & b,
30        21 => a | b,
31        24 => !a,
32        22 => a.wrapping_shl((b & 31) as u32), // Shl
33        23 => a.wrapping_shr((b & 31) as u32), // Shr (arithmetic on i32)
34        25 => (a < b) as i32,                  // signed comparisons → 0/1
35        26 => (a > b) as i32,
36        27 => (a <= b) as i32,
37        28 => (a >= b) as i32,
38        29 | 30 => (a == b) as i32, // Eq / ApproxEq (integer sub-domain: below the 1e-9 relative-tolerance floor, ≈ collapses to =; float-tolerance is the FPU coprocessor)
39        31 => (a != b) as i32,
40        _ => 0,
41    }
42}
43
44/// The Rust model of the soft-core's `shared_divider` unit (truncated signed division): Div /
45/// ExactDiv → quotient, Mod → remainder (dividend's sign), FloorDiv → floor-adjusted quotient;
46/// b==0 → 0. `run_soft_core` issues opcodes 12/13/14/15 here instead of the combinational ALU,
47/// mirroring the RTL (the divider is proven == this by iverilog `tb_shared_divider`).
48pub fn soft_core_divider(opcode: u8, a: i32, b: i32) -> i32 {
49    if b == 0 {
50        return 0;
51    }
52    match opcode {
53        12 | 13 => a.wrapping_div(b), // Div / ExactDiv → quotient
54        15 => a.wrapping_rem(b),      // Mod → remainder
55        14 => {
56            // FloorDiv (toward −∞)
57            let q = a.wrapping_div(b);
58            if a.wrapping_rem(b) != 0 && ((a < 0) != (b < 0)) {
59                q - 1
60            } else {
61                q
62            }
63        }
64        _ => 0,
65    }
66}
67
68// ── WIDTH TOGGLE (VM⇄Fabric parity) — the datapath width is a PARAMETER, not hard-wired to 32 bits.
69// The chip and the VM diverge only because the scalar core is 32-bit while the VM's Int is unbounded (it
70// promotes to BigInt): a benchmark PRNG doing `(seed * 1103515245) % 2147483648` needs a 64-bit intermediate
71// and a `2^31` constant that a 32-bit core cannot hold. So the fix is a WIDTH toggle: one datapath that builds
72// a 32-bit core (small, Sky130 tape-out) or a 64-bit core (VM parity for the i64 range), by computing in i64
73// and SIGN-EXTENDING to `width` bits after each op. `soft_core_alu`/`soft_core_divider` (above) are exactly the
74// `width == 32` case, kept byte-identical; these are the parametric forms the 64-bit core + the future
75// `run_soft_core_w` use.
76
77/// Sign-extend the low `width` bits of a value to a full i64 — a fixed-width datapath's result register.
78/// `width >= 64` is the identity; `width == 32` reproduces the 32-bit core's `as i32` truncation exactly. The
79/// SAME logic yields a 32-, 64-, or any-power-of-two-width core — the toggle.
80pub fn sign_extend_to_width(v: i64, width: u32) -> i64 {
81    if width >= 64 {
82        v
83    } else {
84        let s = 64 - width;
85        (v << s) >> s
86    }
87}
88
89/// Width-parametric model of `alu.v` — computes in i64 and sign-extends to `width` bits. [`soft_core_alu`] is
90/// the `width == 32` case (proven equal by `alu_width_toggle_is_32bit_faithful_and_64bit_wide`). Div/Mod/
91/// FloorDiv route to [`soft_core_divider_w`], mirroring the shared divider.
92pub fn soft_core_alu_w(opcode: u8, a: i64, b: i64, width: u32) -> i64 {
93    let sh = (b as u64 & u64::from(width - 1)) as u32; // shift amount, masked to the datapath width
94    let r = match opcode {
95        8 | 9 => a.wrapping_add(b), // Add / AddAssign
96        10 => a.wrapping_sub(b),    // Sub
97        11 => a.wrapping_mul(b),    // Mul
98        16 => {
99            // DivPow2 (signed ÷2^b, toward zero)
100            let bias = if a < 0 {
101                1i64.wrapping_shl(sh).wrapping_sub(1)
102            } else {
103                0
104            };
105            a.wrapping_add(bias) >> sh
106        }
107        19 => a ^ b,
108        20 => a & b,
109        21 => a | b,
110        24 => !a,
111        22 => a.wrapping_shl(sh),      // Shl
112        23 => a.wrapping_shr(sh),      // Shr (arithmetic)
113        25 => return i64::from(a < b), // signed comparisons → 0/1 (already width-safe)
114        26 => return i64::from(a > b),
115        27 => return i64::from(a <= b),
116        28 => return i64::from(a >= b),
117        29 | 30 => return i64::from(a == b),
118        31 => return i64::from(a != b),
119        _ => return 0,
120    };
121    sign_extend_to_width(r, width)
122}
123
124/// Width-parametric model of the shared divider (truncated signed division), sign-extended to `width`.
125/// [`soft_core_divider`] is the `width == 32` case.
126pub fn soft_core_divider_w(opcode: u8, a: i64, b: i64, width: u32) -> i64 {
127    if b == 0 {
128        return 0;
129    }
130    let r = match opcode {
131        12 | 13 => a.wrapping_div(b), // Div / ExactDiv → quotient
132        15 => a.wrapping_rem(b),      // Mod → remainder
133        14 => {
134            // FloorDiv (toward −∞)
135            let q = a.wrapping_div(b);
136            if a.wrapping_rem(b) != 0 && ((a < 0) != (b < 0)) {
137                q - 1
138            } else {
139                q
140            }
141        }
142        _ => return 0,
143    };
144    sign_extend_to_width(r, width)
145}
146
147// ── AUTO-PROMOTING VALUE-INTEGER ALU/DIVIDER (VM-EXACT) ──────────────────────────────────────────────
148// The datapath's VALUE integers are unbounded, EXACTLY as the VM's `Int` (`crate::vm::value::Value` /
149// `RuntimeValue`): a FAST `Small(i64)` path (the common case, keeping the model fast + the 32-bit oracle
150// byte-identical) that AUTO-PROMOTES to an exact `Big(BigInt)` on i64 overflow, then normalizes back to
151// `Small` when the result fits `i64` again (`RegVal::from_big`, mirroring `RuntimeValue::from_bigint`).
152// So the chip differs NOWHERE from the VM on integers — no `mod 2^width`, no range restriction. The width
153// no longer masks value arithmetic; it is a perf/area knob for the fast path and the raw-i64 output view
154// (`run_soft_core_w`), never a correctness switch. These mirror `crate::semantics::arith`'s `add`/
155// `subtract`/`multiply`/`divide`/`modulo`/`floor_divide` line-for-line (checked-i64 fast path, BigInt
156// exact fallback, truncating Div/Mod vs floor FloorDiv, bitwise/shift on the i64 pattern only — the VM's
157// bitwise/shift path is i64-only and never promotes).
158
159/// The value-integer ALU: `Add`/`Sub`/`Mul` promote to `BigInt` on i64 overflow (VM-exact); `DivPow2`,
160/// bitwise (`^ & | ~`), and shifts compute on the i64 pattern (the VM's bitwise/shift path is i64-only —
161/// SHA/Word programs stay in range); comparisons return `Small(0/1)`. Mirrors `arith::{add,subtract,
162/// multiply}` and `binary_op`'s bitwise/shift arms exactly.
163fn soft_core_alu_regval(opcode: u8, a: &RegVal, b: &RegVal) -> RegVal {
164    match opcode {
165        8 | 9 => match (a, b) {
166            (RegVal::Small(x), RegVal::Small(y)) => match x.checked_add(*y) {
167                Some(s) => RegVal::Small(s),
168                None => RegVal::from_big(a.to_big().add(&b.to_big())),
169            },
170            _ => RegVal::from_big(a.to_big().add(&b.to_big())),
171        },
172        10 => match (a, b) {
173            (RegVal::Small(x), RegVal::Small(y)) => match x.checked_sub(*y) {
174                Some(s) => RegVal::Small(s),
175                None => RegVal::from_big(a.to_big().sub(&b.to_big())),
176            },
177            _ => RegVal::from_big(a.to_big().sub(&b.to_big())),
178        },
179        11 => match (a, b) {
180            (RegVal::Small(x), RegVal::Small(y)) => match x.checked_mul(*y) {
181                Some(p) => RegVal::Small(p),
182                None => RegVal::from_big(a.to_big().mul(&b.to_big())),
183            },
184            _ => RegVal::from_big(a.to_big().mul(&b.to_big())),
185        },
186        16 => {
187            // DivPow2 (signed ÷2^b, toward zero)
188            // The optimizer's lowering of `Div` by a power of two — so it must equal the VM's TRUNCATING
189            // `Div` (`arith::divide`). `a >> sh` with the round-to-zero bias is exactly `a / 2^sh` toward
190            // zero for a `Small`; a `Big` dividend divides exactly through the BigInt truncating `div_rem`.
191            let sh = (b.sm() as u64 & 63) as u32;
192            match a {
193                RegVal::Small(x) => {
194                    let bias = if *x < 0 {
195                        1i64.wrapping_shl(sh).wrapping_sub(1)
196                    } else {
197                        0
198                    };
199                    RegVal::Small(x.wrapping_add(bias) >> sh)
200                }
201                RegVal::Big(x) => {
202                    let divisor = BigInt::from_i64(2).pow(sh);
203                    RegVal::from_big(x.div_rem(&divisor).expect("2^sh is nonzero").0)
204                }
205            }
206        }
207        19 => RegVal::Small(a.sm() ^ b.sm()), // BitXor — VM: Int ^ Int (i64 only)
208        20 => RegVal::Small(a.sm() & b.sm()), // BitAnd
209        21 => RegVal::Small(a.sm() | b.sm()), // BitOr
210        24 => RegVal::Small(!a.sm()),         // Not (bitwise on the i64 pattern)
211        22 => RegVal::Small(a.sm().wrapping_shl(b.sm() as u32)), // Shl — VM: a.wrapping_shl(b as u32)
212        23 => RegVal::Small(a.sm().wrapping_shr(b.sm() as u32)), // Shr — VM: a.wrapping_shr(b as u32)
213        25 => RegVal::Small(i64::from(regval_cmp(a, b) == std::cmp::Ordering::Less)),
214        26 => RegVal::Small(i64::from(regval_cmp(a, b) == std::cmp::Ordering::Greater)),
215        27 => RegVal::Small(i64::from(regval_cmp(a, b) != std::cmp::Ordering::Greater)),
216        28 => RegVal::Small(i64::from(regval_cmp(a, b) != std::cmp::Ordering::Less)),
217        29 | 30 => RegVal::Small(i64::from(regval_cmp(a, b) == std::cmp::Ordering::Equal)),
218        31 => RegVal::Small(i64::from(regval_cmp(a, b) != std::cmp::Ordering::Equal)),
219        _ => RegVal::Small(0),
220    }
221}
222
223/// Exact ordering of two value integers — the fast i64 compare when both are `Small`, else an exact
224/// `BigInt` compare. Mirrors the VM's `compare` (which promotes both operands to the exact tower).
225fn regval_cmp(a: &RegVal, b: &RegVal) -> std::cmp::Ordering {
226    match (a, b) {
227        (RegVal::Small(x), RegVal::Small(y)) => x.cmp(y),
228        _ => a.to_big().cmp(&b.to_big()),
229    }
230}
231
232/// The value-integer shared divider — TRUNCATING `Div`/`ExactDiv` (op 12/13, toward zero), `Mod` (op 15,
233/// remainder carries the dividend's sign), `FloorDiv` (op 14, toward −∞); `b == 0` → `Small(0)` (mirrors
234/// the RTL divider's zero guard). Promotes to `BigInt` EXACTLY as `arith::{divide,modulo,floor_divide}`:
235/// truncating Div's only i64-overflow case is `i64::MIN / -1 = 2^63`, and FloorDiv floors the exact
236/// rational quotient. Big operands divide exactly through `BigInt::div_rem`.
237fn soft_core_divider_regval(opcode: u8, a: &RegVal, b: &RegVal) -> RegVal {
238    if let RegVal::Small(0) = b {
239        return RegVal::Small(0);
240    }
241    if let RegVal::Big(bb) = b {
242        if bb.is_zero() {
243            return RegVal::Small(0);
244        }
245    }
246    match (a, b) {
247        (RegVal::Small(x), RegVal::Small(y)) => match opcode {
248            12 | 13 => match x.checked_div(*y) {
249                // Div / ExactDiv — only i64::MIN/-1 overflows
250                Some(q) => RegVal::Small(q),
251                None => RegVal::from_big(a.to_big().div_rem(&b.to_big()).expect("nonzero").0),
252            },
253            15 => RegVal::Small(x.checked_rem(*y).unwrap_or(0)), // Mod — i64::MIN % -1 == 0
254            14 => match x.checked_div(*y) {
255                // FloorDiv (toward −∞)
256                // The only i64 overflow is `i64::MIN / -1` (an EXACT division, remainder 0 → no floor
257                // adjustment), handled exactly on the BigInt path. Otherwise: the truncating quotient,
258                // decremented when the division is inexact and the operands have opposite signs.
259                Some(q) => {
260                    if x.wrapping_rem(*y) != 0 && ((*x < 0) != (*y < 0)) {
261                        RegVal::Small(q - 1)
262                    } else {
263                        RegVal::Small(q)
264                    }
265                }
266                None => RegVal::from_big(bigint_floor_div(&a.to_big(), &b.to_big())),
267            },
268            _ => RegVal::Small(0),
269        },
270        _ => {
271            let (aa, bb) = (a.to_big(), b.to_big());
272            match opcode {
273                12 | 13 => RegVal::from_big(aa.div_rem(&bb).expect("nonzero").0),
274                15 => RegVal::from_big(aa.div_rem(&bb).expect("nonzero").1),
275                14 => RegVal::from_big(bigint_floor_div(&aa, &bb)),
276                _ => RegVal::Small(0),
277            }
278        }
279    }
280}
281
282/// `⌊a / b⌋` on `BigInt`s (toward −∞) — the exact floor of the rational quotient, matching the VM's
283/// `arith::floor_divide` (`(a/b).floor()`): the truncating quotient, decremented by one when the division
284/// is inexact and the operands have opposite signs.
285fn bigint_floor_div(a: &BigInt, b: &BigInt) -> BigInt {
286    let (q, r) = a.div_rem(b).expect("nonzero divisor");
287    if !r.is_zero() && (a.is_negative() != b.is_negative()) {
288        q.sub(&BigInt::from_i64(1))
289    } else {
290        q
291    }
292}
293
294use logicaffeine_base::BigInt;
295
296/// A VALUE integer register cell — the chip's exact mirror of the VM's `Int`/`BigInt` tower. The common
297/// case is a machine word (`Small`, the FAST path that keeps the 32-bit oracle byte-identical and the
298/// model fast over the 5e8-cycle budget); an integer that overflows the datapath promotes to an exact
299/// `Big` (unbounded [`BigInt`]), EXACTLY as `crate::vm::value::Value` / `RuntimeValue::Int` promotes to
300/// `RuntimeValue::BigInt`. INVARIANT (mirrors `RuntimeValue::from_bigint`): a `Big` never holds a value
301/// that fits `i64` — every arithmetic result normalizes back to `Small` when it fits — so a value has one
302/// canonical representation and the printed decimal is unique. Pointers, list lengths, map tags, iterator
303/// cursors, and register indices stay `Small` (they are addresses, never over-i64), so the structural
304/// heap/pointer machinery reads them through `RegVal::sm` with no BigInt on the hot structural path.
305#[derive(Clone, PartialEq, Debug)]
306pub enum RegVal {
307    Small(i64),
308    Big(BigInt),
309}
310
311impl Default for RegVal {
312    fn default() -> Self {
313        RegVal::Small(0)
314    }
315}
316
317impl RegVal {
318    /// Build a value integer from a `BigInt`, DOWNSIZING to [`RegVal::Small`] whenever it fits `i64` —
319    /// the chip's mirror of `RuntimeValue::from_bigint`, which is what keeps the representation canonical
320    /// (a `Big` is always genuinely over-i64) so the printed decimal is identical to the VM's.
321    fn from_big(b: BigInt) -> RegVal {
322        match b.to_i64() {
323            Some(n) => RegVal::Small(n),
324            None => RegVal::Big(b),
325        }
326    }
327
328    /// This value as a `BigInt` (exact for both arms) — the operand view the overflow path promotes into.
329    fn to_big(&self) -> BigInt {
330        match self {
331            RegVal::Small(n) => BigInt::from_i64(*n),
332            RegVal::Big(b) => b.clone(),
333        }
334    }
335
336    /// The `Small` machine word — the address/length/tag/cursor view used by the structural heap and
337    /// pointer machinery. A `Big` value never reaches here (it is only ever a VALUE integer, never an
338    /// address); the `to_i64().unwrap_or` narrows defensively (the low bits) so a stray over-i64 index
339    /// wraps rather than panicking, exactly as a fixed-width address register would.
340    fn sm(&self) -> i64 {
341        match self {
342            RegVal::Small(n) => *n,
343            RegVal::Big(b) => b.to_i64().unwrap_or(0),
344        }
345    }
346
347    /// The `Small` machine word as a heap/register index.
348    #[inline]
349    fn idx(&self) -> usize {
350        self.sm() as usize
351    }
352}
353
354/// One `Show` output of the soft-core, tagged by the datapath that produced it: an integer `Show`
355/// (opcode 89, the 32-bit ALU/heap result) or a FLOAT `Show` (opcode 212 `ShowF`, an f64 from the
356/// FPU float bank). The chip is dynamically typed at the VM level, but the encoder statically lowers
357/// each `Show` to the one whose operand type it proved — so the model carries the same distinction and
358/// [`ShowVal::fmt`] renders each exactly as the VM does (ints bare, floats via `fmt_f64`).
359#[derive(Clone, PartialEq, Debug)]
360pub enum ShowVal {
361    Int(i64),
362    /// A BIGINT integer `Show` — the same integer datapath as [`ShowVal::Int`], but the value OVERFLOWED
363    /// the machine word and promoted to an exact [`BigInt`], mirroring the VM's `RuntimeValue::BigInt`. The
364    /// 32-bit oracle and every in-range program stay on `Int` (the fast path is unchanged); only a value
365    /// genuinely exceeding `i64` lands here, and [`ShowVal::fmt`] prints its full decimal byte-identically
366    /// to `logicaffeine_base::BigInt`'s `Display` (the same string the VM's `Show` emits).
367    BigInt(BigInt),
368    Float(f64),
369    /// A TEXT `Show` (opcode 217 `ShowText`) — the string decoded from the char-code list on the heap.
370    /// The chip sends the char bytes over UART (reply tag 2); the host reassembles the string. The model
371    /// carries the reconstituted `String` so `fmt` renders it byte-identically to the VM's Text `Show`.
372    Text(String),
373    /// A WORD32 `Show` (opcode 221 `ShowWord32`) — the same 32-bit ALU/heap result as an integer `Show`,
374    /// but the register holds a `Word32` (or an `intOfWord32` unsigned value), so it renders UNSIGNED
375    /// (0..2³²−1) rather than the `i32` two's-complement reinterpretation. Carries the `u32` so `fmt`
376    /// matches the VM's `Word32`/unsigned-`Int` `Show` exactly (SHA-256's hash words exceed 2³¹).
377    Word32(u32),
378}
379impl ShowVal {
380    /// Render exactly as the VM's `Show` does: an integer bare, a float through the ONE float-display
381    /// authority (`logicaffeine_data::fmt::fmt_f64` — the same path `RuntimeValue::to_display_string`
382    /// uses), a Text as its raw characters (`RuntimeValue::Text(s) => s`), a `Word32` as its UNSIGNED
383    /// decimal value (`Word32`'s `Display` prints the underlying `u32`), so the model's output string is
384    /// byte-identical to `vm_outcome`.
385    pub fn fmt(&self) -> String {
386        match self {
387            ShowVal::Int(v) => v.to_string(),
388            ShowVal::BigInt(b) => b.to_string(),
389            ShowVal::Float(f) => logicaffeine_data::fmt::fmt_f64(*f),
390            ShowVal::Text(s) => s.clone(),
391            ShowVal::Word32(w) => w.to_string(),
392        }
393    }
394    /// The integer payload of an integer `Show` — panics on a float / text / word `Show`. Used only by
395    /// the pure integer differential/testbench paths (`run_soft_core`), which never emit
396    /// `ShowF`/`ShowText`/`ShowWord32`. Returns the full `i64` (the datapath width toggle carries 64-bit
397    /// values at `width == 64`; the 32-bit core's values are the low 32 bits sign-extended, identical).
398    pub fn as_i64(&self) -> i64 {
399        match self {
400            ShowVal::Int(v) => *v,
401            ShowVal::BigInt(b) => panic!("integer soft-core run emitted a bigint Show ({b})"),
402            ShowVal::Float(f) => panic!("integer soft-core run emitted a float Show ({f})"),
403            ShowVal::Text(s) => panic!("integer soft-core run emitted a text Show ({s:?})"),
404            ShowVal::Word32(w) => panic!("integer soft-core run emitted a word32 Show ({w})"),
405        }
406    }
407}
408
409/// A cycle-accurate Rust model of `benchmarks/silicon/soft_core.v` — fetch/decode/execute over a
410/// chip-word program (`[opcode:8][dst:8][a:8][b:8]`; LoadConst/Jump use `[op][dst][imm:16]`). Returns
411/// the sequence of `Show` outputs (integer or float — see [`ShowVal`]). Mirrors the RTL `case` exactly;
412/// the executable spec the ≡VM differential checks and the iverilog testbench agrees with.
413///
414/// FLOAT ARCHITECTURE (mirrors `soft_core.v`'s `fregs`/`fpu.v` integration): a parallel 64-bit float
415/// bank `fregs` sits beside the 32-bit `regs`, same index space — VM register R holds either an int in
416/// `regs[R]` or an f64 in `fregs[R]`, disambiguated by the op's statically-lowered type. The float
417/// opcodes (chip-synthetic, above the ISA table like `LoadConstWide`=200, so no `Op` variant / no ISA
418/// drift): `FConst`=201 (3-word: op + 2 immediate words = the f64 bits), `FAdd`=202 `FSub`=203
419/// `FMul`=204 `FDiv`=205 `FSqrt`=206 (dispatch to fpu.v, bit-exact via real `f64`), `FLt`=207 `FEq`=208
420/// (write an int 0/1 into `regs` so branches work), `IntToF`=209 `FToInt`=210 (the Int↔Float boundary),
421/// `FMove`=211, `ShowF`=212. The FLOAT-HEAP PLANE (213..=215) lets a `Seq of Float` ride the heap: the
422/// structure (header + int cells) lives in `heap` and the f64 payloads on the parallel `fheap` plane
423/// (`FListPush`=213 grows both + COWs both, `FIndex`=214 reads a payload, `FSetIndex`=215 writes one).
424///
425/// The TEXT / SEQ BUILTIN opcodes (chip-synthetic 233..=236) lower the deterministic string/fill/parse
426/// builtins the CHIP compiler emits from source: `Chr`=233 (a 1-char Text `[1, codepoint]` from a codepoint,
427/// trap on an invalid one), `TextContains`=234 (a substring char-scan over two Text char-lists → 0/1),
428/// `RepeatSeq`=235 (an `n`-slot fresh Seq, every slot the same SCALAR element), `ParseInt`=236 (a bounded
429/// decimal char-scan over a Text char-list → the i64 value, trap on a non-numeric / empty / overflowing
430/// input). Each is byte-identical to the matching `semantics::builtins::call_builtin` arm; the remaining
431/// reference/host builtin families (parseFloat/format/uuid/…) stay off the chip (the encoder rejects them
432/// loudly — see `full_asm::encode_op`).
433///
434/// The integer datapath WIDTH is fixed at 32 bits (the tape-out core). For VM parity on 64-bit-range
435/// arithmetic, use [`run_soft_core_typed_w`] with `width == 64`.
436thread_local! {
437    /// DATA-segment preload for [`run_soft_core_typed_preloaded`]: seeds `heap[0..]` before the run so the
438    /// model mirrors the SoC's UART DATA phase (weights streamed into the heap; `loaded()` = the Seq at ptr 0).
439    static HEAP_PRELOAD: std::cell::RefCell<Vec<i64>> = const { std::cell::RefCell::new(Vec::new()) };
440}
441
442/// Run with `preload` seeded into the heap as `heap[0..preload.len()]` — the model twin of the SoC's weight
443/// DATA phase. With `preload = [len, w0, w1, …]` that is a `Seq of Int` at pointer 0, so the compiled
444/// `loaded()` (pointer 0) makes `item i of loaded()` read `heap[i]`. The bump allocator continues after it.
445pub fn run_soft_core_typed_preloaded(
446    imem: &[u32],
447    max_cycles: usize,
448    preload: &[i64],
449) -> Vec<ShowVal> {
450    HEAP_PRELOAD.with(|h| *h.borrow_mut() = preload.to_vec());
451    let out = run_soft_core_typed_w(imem, max_cycles, 32);
452    HEAP_PRELOAD.with(|h| h.borrow_mut().clear());
453    out
454}
455
456pub fn run_soft_core_typed(imem: &[u32], max_cycles: usize) -> Vec<ShowVal> {
457    run_soft_core_typed_w(imem, max_cycles, 32)
458}
459
460/// The WIDTH-PARAMETRIC form of [`run_soft_core_typed`] — the real fetch/decode/execute loop. Every
461/// integer VALUE is an auto-promoting [`RegVal`] (fast `Small(i64)` + exact `Big(BigInt)` on overflow),
462/// so the datapath's `Int` is UNBOUNDED exactly like the VM's — the chip differs NOWHERE from the VM on
463/// integers at ANY `width`. This is a REFRAME of the old width toggle: `width` no longer masks value
464/// arithmetic (a product that overflows i32/i64 PROMOTES, matching the VM, rather than wrapping), so
465/// `width == 32` and `width == 64` both equal `vm_outcome` on the `ShowVal` output. `width` survives as a
466/// perf/area knob and the datapath-width parameter for the raw-i64 OUTPUT VIEW ([`run_soft_core_w`], which
467/// masks each value to `width` bits to model a fixed-width hardware output register). Addresses/iterators
468/// stay `usize`; the float bank stays f64 (width-independent).
469pub fn run_soft_core_typed_w(imem: &[u32], max_cycles: usize, width: u32) -> Vec<ShowVal> {
470    run_soft_core_traced(imem, max_cycles, width).0
471}
472
473/// How the core stopped with a FAILURE (a trap-halt rather than a clean `Halt`).
474#[derive(Clone, Debug, PartialEq, Eq)]
475pub enum ChipFailure {
476    /// A `FailWith` whose reason the program streamed over the Text port just before it (`reasoned`).
477    Reason(String),
478    /// A trap with no text: a failed policy check, an invalid codepoint, a `parseInt` error.
479    Trap,
480}
481
482/// A run of the core: everything it showed, and how it failed if it did.
483#[derive(Clone, Debug)]
484pub struct SoftCoreRun {
485    pub shows: Vec<ShowVal>,
486    pub failure: Option<ChipFailure>,
487}
488
489/// Run `imem` and report the failure contract the RTL's `failed`/`reasoned` ports carry: a reasoned
490/// trap's last Text record IS its reason (so it leaves `shows`); a trap without text is `Trap`.
491pub fn run_soft_core_outcome(imem: &[u32], max_cycles: usize) -> SoftCoreRun {
492    let (mut shows, trap) = run_soft_core_traced(imem, max_cycles, 32);
493    let failure = match trap {
494        None => None,
495        Some(false) => Some(ChipFailure::Trap),
496        Some(true) => match shows.pop() {
497            Some(ShowVal::Text(reason)) => Some(ChipFailure::Reason(reason)),
498            other => {
499                shows.extend(other);
500                Some(ChipFailure::Trap)
501            }
502        },
503    };
504    SoftCoreRun { shows, failure }
505}
506
507/// The fetch/decode/execute loop: the shows, and `Some(reasoned)` when it trap-halted.
508fn run_soft_core_traced(
509    imem: &[u32],
510    max_cycles: usize,
511    width: u32,
512) -> (Vec<ShowVal>, Option<bool>) {
513    let mut trap: Option<bool> = None;
514    // The typed loop is now width-TRANSPARENT (value integers are unbounded, VM-exact). `width` is kept in
515    // the signature for API stability and for the raw-i64 output view; the loop itself ignores it.
516    let _ = width;
517    // The value-integer register file — each cell an auto-promoting `RegVal` (a FAST `Small(i64)` common
518    // case + an exact `Big(BigInt)` overflow fallback), so a VALUE integer is UNBOUNDED exactly like the
519    // VM's `Int`. Pointers, lengths, tags, and cursors live here too but always as `Small` (they are
520    // addresses, never over-i64), read through `.sm()`/`.idx()`.
521    let mut regs: [RegVal; 256] = std::array::from_fn(|_| RegVal::Small(0));
522    // The 64-bit FLOAT BANK: same 256-entry index space as `regs`, mirroring soft_core.v's `fregs`.
523    // A register holds an f64 here when the op that wrote it was a float op; an int lives in `regs`.
524    // Bits are carried EXACTLY (`f64::from_bits`/`to_bits`) so the model == the RTL == Rust f64. The float
525    // bank is WIDTH-INDEPENDENT (IEEE-754 f64) — the width toggle only governs the integer datapath.
526    let mut fregs = [0.0f64; 256];
527    // The 256-BIT BIGINT LANE: an 8×256-bit wide register bank (4 little-endian u64 limbs each),
528    // beside the 32-bit regfile. `BigIntWide` (op 114) routes it to the bigint_alu/div/mul blocks.
529    let mut wide: [[u64; 4]; 8] = [[0u64; 4]; 8];
530    let mut pc: usize = 0;
531    let mut out: Vec<ShowVal> = Vec::new();
532    // Call/return frame stack: (saved int window, saved float window, return pc, result reg) — mirrors
533    // soft_core.v (the frame engine snapshots the whole 256-register window, both banks, per call).
534    let mut frames: Vec<([RegVal; 256], [f64; 256], usize, usize)> = Vec::new();
535    // The LOAD-STORE unit's heap: a flat data memory holding structured values. A list at pointer `p`
536    // is `heap[p] = length` followed by the `length` elements at `heap[p+1..=p+length]`, so the 1-based
537    // `item i of xs` is `heap[p + i]` (mirrors `mem_unit.v` + the VM's `RuntimeValue::List`). A cell holds
538    // a `RegVal` because a collection ELEMENT can be an over-i64 `Big` value (a `Seq of Int` of big ints);
539    // headers/tags/pointers stay `Small`, read through `.sm()`.
540    let mut heap: Vec<RegVal> =
541        HEAP_PRELOAD.with(|h| h.borrow().iter().map(|&x| RegVal::Small(x)).collect());
542    // The FLOAT-HEAP PLANE — a dense f64 payload plane running PARALLEL to `heap` at the SAME indices
543    // (RTL: `fdmem`, two 32-bit BRAMs beside `dmem`). A `Seq of Float` handle is still an INT pointer into
544    // `heap` (which keeps the `[length, …]` header + all structure); its element PAYLOADS ride `fheap` at
545    // the same slots. INVARIANT: `fheap.len() == heap.len()` ALWAYS — enforced by a single resize at the
546    // bottom of the fetch loop, so every op that grows `heap` (int lists, maps, ranges, …) leaves a
547    // matching `0.0` float cell behind with no per-op bookkeeping. The float-list ops (213/214/215) read
548    // and write this plane; the COW in FListPush copies BOTH planes so a float seq's payload survives.
549    let mut fheap: Vec<f64> = vec![0.0; heap.len()]; // INVARIANT: fheap.len() == heap.len() (preload too)
550                                                     // The iterator stack: each `for x in xs` pushes a `(collection pointer, 1-based cursor)`; IterNext
551                                                     // reads `heap[ptr + cursor]` and advances, jumping to `exit` when the cursor passes the length.
552    let mut iters: Vec<(usize, usize)> = Vec::new();
553    // A MAP is a header-tagged heap block `[MAP_TAG|npairs, k0, v0, k1, v1, …]`. The tag bit (never set by a
554    // plain list length) lets the shared Length/Contains ops branch to key-scan behaviour for maps and stay
555    // positional for lists. Each key cell `kᵢ` is EITHER a char-list POINTER (a `Map of Text to …`, accessed
556    // through the string-key ops SetIndex/Index 57/59 which compare with `key_eq`) OR a direct INTEGER key (a
557    // `Map of Int to …`, accessed through the int-key ops MapSetIndexInt/MapIndexInt 224/223 which compare with
558    // `==`). The encoder picks the op family from the key expression's static type — the run loop never mixes
559    // them within one map, so the key cell's interpretation is unambiguous per access op.
560    const MAP_TAG: i64 = 0x4000_0000;
561    // char-list key equality: the string at `p` equals the string at `q` (same length + same codepoints).
562    let key_eq = |heap: &[RegVal], p: usize, q: usize| -> bool {
563        if heap[p].sm() != heap[q].sm() {
564            return false;
565        }
566        for j in 1..=(heap[p].sm() as usize) {
567            if heap[p + j].sm() != heap[q + j].sm() {
568                return false;
569            }
570        }
571        true
572    };
573    // The globals table — Main top-level `Let`s promoted to globals when a function body references them.
574    // It lives OUTSIDE the register-window frame stack, so a global survives Call/Return (a callee reads
575    // the same slot the caller wrote), exactly as the VM's runtime globals table does.
576    let mut globals: [RegVal; 256] = std::array::from_fn(|_| RegVal::Small(0));
577    // Select arms: `Await the first of:` registers `(chan_reg, var_reg)` recv arms (chan_reg = usize::MAX
578    // marks an `After ticks` timeout arm); SelectWait fires the FIRST ready recv (deterministic), else the
579    // timeout arm, and reports the fired arm index.
580    let mut sel_arms: Vec<(usize, usize)> = Vec::new();
581    // The networking coprocessor's OFFLINE LOOPBACK inbox — the single-node projection of the relay:
582    // `NetSend to <peer>` PUBLISHES `(peer key, message)` here; `NetAwait from <peer>` DRAINS the entry
583    // whose key matches (the VM's `offline_loopback` — a message a node sends to a peer it also awaits
584    // comes back to it). The peer key is the `NetMakePeer` handle (Send and Await name the same peer).
585    let mut inbox: Vec<(RegVal, RegVal)> = Vec::new();
586    let mut peer_seq: i64 = 0;
587    for _ in 0..max_cycles {
588        if pc >= imem.len() {
589            break;
590        }
591        let instr = imem[pc];
592        let opcode = (instr >> 24) as u8;
593        let dst = ((instr >> 16) & 0xff) as usize;
594        let ra = ((instr >> 8) & 0xff) as usize;
595        let rb = (instr & 0xff) as usize;
596        let imm = i64::from(instr as i16); // LoadConst immediate: low 16 bits, SIGN-extended (−32768..=32767); fits any width, no mask
597        let target = ((instr >> 16) & 0xff) as usize; // 8-bit entry byte (Spawn/SpawnHandle; wide targets read below)
598                                                      // Branch truthiness (`av == 0`) tests the VALUE: a `Big` is never zero (canonical repr), so
599                                                      // `is_zero_val` is the exact truthiness the VM's branch uses. Arithmetic arms take the full
600                                                      // `RegVal` (`&regs[..]`) so an over-i64 `Big` operand promotes correctly; structural arms read the
601                                                      // `.sm()` i64 address view.
602        let is_zero_val = |v: &RegVal| matches!(v, RegVal::Small(0));
603        match opcode {
604            // Reversible/adiabatic charge-recovery core (wire opcodes 0..4) — the abelian bijection
605            // datapath (`rev_alu_core.v`), now integrated into the CPU so ONE core executes every op.
606            // The reversible core's Add/Sub/Neg AUTO-PROMOTE like every value add/sub/neg (VM-exact); Xor
607            // is the i64-only bitwise the VM's `Int ^ Int` computes. `place op= delta` in-place.
608            0 => {
609                let r = soft_core_alu_regval(8, &regs[dst], &regs[ra]);
610                regs[dst] = r;
611                pc += 1;
612            } // RevAdd { place=dst, delta=ra }
613            1 => {
614                let r = soft_core_alu_regval(10, &regs[dst], &regs[ra]);
615                regs[dst] = r;
616                pc += 1;
617            } // RevSub
618            2 => {
619                let r = RegVal::Small(regs[dst].sm() ^ regs[ra].sm());
620                regs[dst] = r;
621                pc += 1;
622            } // RevXor (i64 pattern)
623            3 => {
624                let r = soft_core_alu_regval(10, &RegVal::Small(0), &regs[dst]);
625                regs[dst] = r;
626                pc += 1;
627            } // RevNeg { place=dst } = 0 - place
628            4 => {
629                regs.swap(dst, ra);
630                pc += 1;
631            } // RevSwap { a=dst, b=ra }
632            5 => {
633                regs[dst] = RegVal::Small(imm);
634                pc += 1;
635            } // LoadConst — sign-extended 16-bit inline immediate (fits any width)
636            200 => {
637                regs[dst] = RegVal::Small(i64::from(imem[pc + 1] as i32));
638                pc += 2;
639            } // LoadConstWide — sign-extended 32-bit immediate in the SECOND word (chip-synthetic opcode, no VM Op)
640            222 => {
641                // LoadConstWide64 { dst } — 3-word: op + [bits 31:0][bits 63:32] = the full i64 constant
642                // The full i64 constant is retired EXACTLY, irrespective of datapath width — NO sign-extension
643                // to `width`. This is what lets the full core reproduce a constant wider than i32 (`% 2^31`,
644                // `% 2^32` in the benchmark PRNGs, or a full-range i64 literal) at width 32 as faithfully as at
645                // 64: the value plane auto-promotes to BigInt on overflow like the VM's unbounded `Int`, so the
646                // width is a fast-path/area knob, not a correctness switch. Mirrors `FConst` (op 201): low word
647                // then high word. The encoder emits op 222 for every over-i32 constant at every width.
648                let bits = (imem[pc + 1] as u64) | ((imem[pc + 2] as u64) << 32);
649                regs[dst] = RegVal::Small(bits as i64);
650                pc += 3;
651            }
652            // ── FLOAT DATAPATH (chip-synthetic opcodes 201..=212, mirroring soft_core.v's fregs + fpu.v).
653            // A float register lives in `fregs`; ints stay in `regs`. Every arithmetic result is computed
654            // with real Rust `f64` (bit-identical to fpu.v's IEEE-754 RNE units) via from_bits/to_bits.
655            201 => {
656                // FConst { dst } — 3-word: op + [bits 31:0][bits 63:32]
657                let bits = (imem[pc + 1] as u64) | ((imem[pc + 2] as u64) << 32);
658                fregs[dst] = f64::from_bits(bits);
659                pc += 3;
660            }
661            202 => {
662                fregs[dst] = fregs[ra] + fregs[rb];
663                pc += 1;
664            } // FAdd  (fp_addsub sub=0)
665            203 => {
666                fregs[dst] = fregs[ra] - fregs[rb];
667                pc += 1;
668            } // FSub  (fp_addsub sub=1)
669            204 => {
670                fregs[dst] = fregs[ra] * fregs[rb];
671                pc += 1;
672            } // FMul  (fp_mul)
673            205 => {
674                fregs[dst] = fregs[ra] / fregs[rb];
675                pc += 1;
676            } // FDiv  (fp_div, multi-cycle)
677            206 => {
678                fregs[dst] = fregs[ra].sqrt();
679                pc += 1;
680            } // FSqrt (fp_sqrt, multi-cycle)
681            207 => {
682                regs[dst] = RegVal::Small(i64::from(fregs[ra] < fregs[rb]));
683                pc += 1;
684            } // FLt -> int 0/1 (fp_cmp lt)
685            208 => {
686                regs[dst] = RegVal::Small(i64::from(fregs[ra] == fregs[rb]));
687                pc += 1;
688            } // FEq -> int 0/1 (fp_cmp eq)
689            229 => {
690                regs[dst] = RegVal::Small(i64::from(fregs[ra] <= fregs[rb]));
691                pc += 1;
692            } // FLe -> int 0/1 (fp_cmp le = lt|eq; NaN-false, matching the VM's f64 `<=`)
693            230 => {
694                regs[dst] = RegVal::Small(i64::from(fregs[ra] != fregs[rb]));
695                pc += 1;
696            } // FNe -> int 0/1 (fp_cmp ne = !eq; NaN-true, matching the VM's f64 `!=`)
697            // `is approximately` has NO dedicated opcode: the encoder DECOMPOSES it into FEq/FSub/FAbs (36)/
698            // FMax (38)/FConst/FMul/FLe + an int BitOr, reproducing `logos_approx_eq` bit-for-bit with the SAME
699            // ops the model already runs above and in the op-38 float coprocessor below.
700            209 => {
701                fregs[dst] = regs[ra].to_big().to_f64();
702                pc += 1;
703            } // IntToF { dst, src=ra } — Int -> Float boundary (exact for Big too, VM: `n as f64`/`b.to_f64()`)
704            210 => {
705                regs[dst] = RegVal::Small(fregs[ra] as i64);
706                pc += 1;
707            } // FToInt { dst, src=ra } — Float -> Int (truncation toward zero)
708            211 => {
709                fregs[dst] = fregs[ra];
710                pc += 1;
711            } // FMove { dst, src=ra } — float register copy
712            212 => {
713                out.push(ShowVal::Float(fregs[ra]));
714                pc += 1;
715            } // ShowF { src=ra } — float Show
716            // ── FLOAT-HEAP PLANE (chip-synthetic opcodes 213..=215): a `Seq of Float`'s structure lives
717            // in the INT heap (header `[length, …]` + int placeholder cells), its f64 element payloads on
718            // the parallel `fheap` plane at the SAME slots. Mirrors soft_core.v's `fdmem` beside `dmem`.
719            213 => {
720                // FListPush { list=dst, value=ra } — integer ListPush structure + float payload
721                let ptr = regs[dst].idx();
722                let len = heap[ptr].sm() as usize;
723                let slot;
724                if ptr + 1 + len == heap.len() {
725                    // list is on top of the heap: append in place
726                    slot = heap.len();
727                    heap.push(RegVal::Small(0)); // int placeholder cell (the payload rides fheap)
728                    fheap.push(0.0); // keep the plane aligned within the op
729                    heap[ptr] = RegVal::Small((len + 1) as i64);
730                } else {
731                    // else copy-on-write to a fresh block (value semantics)
732                    let newptr = heap.len();
733                    heap.push(RegVal::Small((len + 1) as i64)); // header
734                    fheap.push(0.0);
735                    for k in 1..=len {
736                        // COPY BOTH PLANES — the float seq's payload survives
737                        let v = heap[ptr + k].clone();
738                        let f = fheap[ptr + k];
739                        heap.push(v);
740                        fheap.push(f);
741                    }
742                    slot = heap.len();
743                    heap.push(RegVal::Small(0)); // the appended int placeholder
744                    fheap.push(0.0);
745                    regs[dst] = RegVal::Small(newptr as i64);
746                }
747                fheap[slot] = fregs[ra]; // write the f64 payload at the appended slot
748                pc += 1;
749            }
750            214 => {
751                // FIndex { dst, coll=ra, index=rb } — read the f64 payload (1-based)
752                fregs[dst] = fheap[regs[ra].idx() + regs[rb].idx()];
753                pc += 1;
754            }
755            215 => {
756                // FSetIndex { coll=dst, index=ra, value=rb } — write the f64 payload (1-based)
757                fheap[regs[dst].idx() + regs[ra].idx()] = fregs[rb];
758                pc += 1;
759            }
760            // ── TEXT (string) PLANE (chip-synthetic opcodes 216/217): a `Text` is a char-code list on the
761            // int heap. `TextConst` copies a string's char block out of the imem STRING-CONSTANT POOL into
762            // a fresh heap char-list `[len, chars…]`; `ShowText` decodes such a list to a UTF-8 string.
763            216 => {
764                // TextConst { dst } — 2-word: [216][dst][len:16] ‖ pool_word_offset
765                let len = ((instr & 0xffff) as usize) & 0xffff;
766                let off = imem[pc + 1] as usize; // imem index of c0 (into the pool, laid out after the code)
767                let ptr = heap.len();
768                heap.push(RegVal::Small(len as i64)); // header
769                for k in 0..len {
770                    heap.push(RegVal::Small(i64::from(imem[off + k])));
771                } // copy the codepoints (unsigned codepoint)
772                regs[dst] = RegVal::Small(ptr as i64);
773                pc += 2;
774            }
775            217 => {
776                // ShowText { src=ra } — decode the char list to a UTF-8 string
777                let ptr = regs[ra].idx();
778                let len = heap[ptr].sm() as usize;
779                let mut s = String::with_capacity(len);
780                for k in 1..=len {
781                    // A char cell is an i32 CODE POINT (as `TextConst`/`FormatValue`/Concat store them).
782                    if let Some(ch) = char::from_u32(heap[ptr + k].sm() as u32) {
783                        s.push(ch);
784                    }
785                }
786                out.push(ShowVal::Text(s));
787                pc += 1;
788            }
789            218 => {
790                // TextCharAt { dst, coll=ra, index=rb } — a single-char Text
791                // The VM's `item i of text` returns a ONE-character string; the chip builds a fresh
792                // `[1, codepoint]` char-list so a subsequent `char == " "` routes to the char-list compare.
793                let cp = heap[regs[ra].idx() + regs[rb].idx()].clone(); // the 1-based codepoint
794                let ptr = heap.len();
795                heap.push(RegVal::Small(1));
796                heap.push(cp);
797                regs[dst] = RegVal::Small(ptr as i64);
798                pc += 1;
799            }
800            219 | 220 => {
801                // TextEq (219) / TextNe (220) — char-list CONTENT compare
802                // Text `equals`/`is not` compares by content (same length + same codepoints), mirroring the
803                // VM's `values_equal` for two Texts. 219 yields 1 on equal; 220 yields its negation.
804                let p = regs[ra].idx();
805                let q = regs[rb].idx();
806                let mut equal = heap[p] == heap[q];
807                if equal {
808                    for j in 1..=(heap[p].sm() as usize) {
809                        if heap[p + j] != heap[q + j] {
810                            equal = false;
811                            break;
812                        }
813                    }
814                }
815                regs[dst] = RegVal::Small(if opcode == 219 {
816                    i64::from(equal)
817                } else {
818                    i64::from(!equal)
819                });
820                pc += 1;
821            }
822            6 => {
823                regs[dst] = regs[ra].clone();
824                pc += 1;
825            } // Move
826            // Branch targets are 16-bit, SPLIT so the low byte sits in the scalar core's byte-1 target
827            // position: a scalar program (target ≤255, high byte 0) decodes byte-identically, so the wide
828            // format is a strict superset of the deployed scalar ISA and the scalar core is untouched.
829            34 => pc = (((instr >> 16) & 0xff) | (((instr >> 8) & 0xff) << 8)) as usize, // Jump {t_lo=byte1, t_hi=byte2}
830            35 => {
831                if is_zero_val(&regs[ra]) {
832                    pc = (((instr >> 16) & 0xff) | ((instr & 0xff) << 8)) as usize
833                } else {
834                    pc += 1
835                }
836            } // JumpIfFalse {t_lo=byte1, cond=byte2, t_hi=byte3}
837            36 => {
838                if !is_zero_val(&regs[ra]) {
839                    pc = (((instr >> 16) & 0xff) | ((instr & 0xff) << 8)) as usize
840                } else {
841                    pc += 1
842                }
843            } // JumpIfTrue {t_lo=byte1, cond=byte2, t_hi=byte3}
844            89 => {
845                out.push(match &regs[ra] {
846                    // Show (integer datapath) — full value, exact
847                    RegVal::Small(v) => ShowVal::Int(*v),
848                    RegVal::Big(b) => ShowVal::BigInt(b.clone()),
849                });
850                pc += 1;
851            }
852            221 => {
853                out.push(ShowVal::Word32(regs[ra].sm() as u32));
854                pc += 1;
855            } // ShowWord32 — same 32-bit result, rendered UNSIGNED (Word32/intOfWord32 Show)
856            43 => break, // Halt
857            // FailWith {reasoned=rb} — the unrecoverable trap: the core halts with the error strobe raised;
858            // rb=1 says the reason was streamed over the Text port just before. Pre-trap output stands.
859            91 => {
860                trap = Some(rb != 0);
861                break;
862            }
863            37 => {
864                // Call — TWO words: [37][result:8][args_start:8][arg_count:8] then [entry:32]
865                // Hardware arg-copy calling convention: the callee runs in a FRESH 256-register frame with
866                // the caller's r[args_start..args_start+arg_count] seeded into r0.., matching the VM's
867                // windowed convention (a function reads its params at its window base r0..). The caller's
868                // whole frame is saved and restored on Return, so argument passing never clobbers a caller
869                // register. result=dst byte, args_start=ra byte, arg_count=rb byte; the callee's imem entry
870                // index is the full 32-bit SECOND word (so it reaches a program of any length).
871                // The arg-copy seeds BOTH banks (int + float) at each arg slot — a float param rides
872                // `fregs`, an int param rides `regs`; only the statically-typed one is read in the body.
873                let arg_cnt = rb;
874                let entry = imem[pc + 1] as usize;
875                let mut callee: [RegVal; 256] = std::array::from_fn(|_| RegVal::Small(0));
876                let mut callee_f = [0.0f64; 256];
877                for i in 0..arg_cnt {
878                    callee[i] = regs[ra + i].clone();
879                    callee_f[i] = fregs[ra + i];
880                }
881                frames.push((regs, fregs, pc + 2, dst));
882                regs = callee;
883                fregs = callee_f;
884                pc = entry;
885            }
886            41 => {
887                // Return {src=ra}
888                // Capture BOTH banks at the source slot; restore the caller window and place both into
889                // its result register. The caller reads only the correctly-typed one (the encoder knows
890                // dst's type at the call site), so a type-agnostic Return needs no float-return opcode.
891                let rv = regs[ra].clone();
892                let rvf = fregs[ra];
893                match frames.pop() {
894                    Some((saved, saved_f, ret_pc, d)) => {
895                        regs = saved;
896                        fregs = saved_f;
897                        regs[d] = rv;
898                        fregs[d] = rvf;
899                        pc = ret_pc;
900                    }
901                    None => break,
902                }
903            }
904            42 => match frames.pop() {
905                // ReturnNothing
906                Some((saved, saved_f, ret_pc, _)) => {
907                    regs = saved;
908                    fregs = saved_f;
909                    pc = ret_pc;
910                }
911                None => break,
912            },
913            40 => {
914                // MakeClosure { dst, func=a-byte } — closure = [entry] (non-capturing)
915                let entry = ((instr >> 8) & 0xff) as i64;
916                let ptr = heap.len();
917                heap.push(RegVal::Small(entry));
918                regs[dst] = RegVal::Small(ptr as i64);
919                pc += 1;
920            }
921            39 => {
922                // CallValue { dst=result, callee=ra, b: args_start(hi nibble) | arg_count(lo nibble) }
923                // Indirect call through a closure value `[entry]`. The frame is the CALLER's (cloned, then
924                // restored on Return), so captures and the closure's static body share one register window —
925                // but the ARGS ride the caller's window at `args_start`, while a compiled closure body reads
926                // its parameters at its window base r0. So window-copy R[args_start..+arg_count] → R[0..] in
927                // the cloned frame before entering (arg_count 0 = same-frame, the hand-built closure form).
928                let entry = heap[regs[ra].idx()].idx();
929                let args_start = ((instr >> 4) & 0xf) as usize;
930                let arg_count = (instr & 0xf) as usize;
931                let mut callee = regs.clone();
932                let mut callee_f = fregs;
933                for k in 0..arg_count {
934                    callee[k] = regs[args_start + k].clone();
935                    callee_f[k] = fregs[args_start + k]; // seed BOTH banks (a float closure arg rides fregs)
936                }
937                frames.push((regs, fregs, pc + 1, dst));
938                regs = callee;
939                fregs = callee_f;
940                pc = entry;
941            }
942            // ── LOAD-STORE unit (structured heap) ──────────────────────────────────────────
943            48 | 67 => {
944                // NewList / NewTuple { dst, start=ra, count=rb }
945                let count = rb; // element count (≤ regfile width); tuple ≡ list layout
946                let ptr = heap.len();
947                heap.push(RegVal::Small(count as i64));
948                for i in 0..count {
949                    heap.push(regs[(ra + i) & 0xff].clone());
950                }
951                regs[dst] = RegVal::Small(ptr as i64);
952                pc += 1;
953            }
954            49 | 50 | 90 | 82 => {
955                let ptr = heap.len();
956                heap.push(RegVal::Small(0));
957                regs[dst] = RegVal::Small(ptr as i64);
958                pc += 1;
959            } // NewEmptyList / NewEmptyListI32 / Args (offline argv) / NewCrdt (seq/set = empty collection; single-replica projection)
960            54 | 83 => {
961                // ListPush { list=dst, value=ra } / CrdtAppend (seq CRDT = ordered list, single-replica projection)
962                let ptr = regs[dst].idx();
963                let len = heap[ptr].sm() as usize;
964                if ptr + 1 + len == heap.len() {
965                    // list is on top of the heap: append in place
966                    heap.push(regs[ra].clone());
967                    heap[ptr] = RegVal::Small((len + 1) as i64);
968                } else {
969                    // else copy-on-write to a fresh block (value semantics)
970                    let newptr = heap.len();
971                    heap.push(RegVal::Small((len + 1) as i64));
972                    for k in 1..=len {
973                        let v = heap[ptr + k].clone();
974                        heap.push(v);
975                    }
976                    heap.push(regs[ra].clone());
977                    regs[dst] = RegVal::Small(newptr as i64);
978                }
979                pc += 1;
980            }
981            59 | 60 => {
982                // Index / IndexUnchecked { dst, coll=ra, index/key=rb }
983                let ptr = regs[ra].idx();
984                if heap[ptr].sm() & MAP_TAG != 0 {
985                    // MAP: look up value by char-list key
986                    let np = (heap[ptr].sm() & !MAP_TAG) as usize;
987                    let key = regs[rb].idx();
988                    let mut val = RegVal::Small(0);
989                    for i in 0..np {
990                        if key_eq(&heap, key, heap[ptr + 1 + 2 * i].idx()) {
991                            val = heap[ptr + 2 + 2 * i].clone();
992                            break;
993                        }
994                    }
995                    regs[dst] = val;
996                } else {
997                    // LIST: positional (1-based)
998                    regs[dst] = heap[ptr + regs[rb].idx()].clone();
999                }
1000                pc += 1;
1001            }
1002            223 => {
1003                // MapIndexInt { dst, map=ra, key=rb } — lookup by INTEGER key
1004                // `item k of m` for a `Map of Int to …`: scan the key cells with a direct integer `==` (the key
1005                // cell holds the int key itself, NOT a heap pointer — the encoder emits this op only when the key
1006                // is statically non-Text). A missing key yields 0 here — the fabric engine's hardware-natural
1007                // default. NOTE: this DIVERGES from the scalar tiers, where `item k of m` on an absent key is a
1008                // runtime TRAP (interpreter/VM/C/riscv); the whole-program corpora reach this op only through
1009                // `contains`-guarded reads, so the gap is latent. Aligning the fabric to trap is a fabric call.
1010                let ptr = regs[ra].idx();
1011                let np = (heap[ptr].sm() & !MAP_TAG) as usize;
1012                let key = regs[rb].clone();
1013                let mut val = RegVal::Small(0);
1014                for i in 0..np {
1015                    if heap[ptr + 1 + 2 * i] == key {
1016                        val = heap[ptr + 2 + 2 * i].clone();
1017                        break;
1018                    }
1019                }
1020                regs[dst] = val;
1021                pc += 1;
1022            }
1023            62 => {
1024                // Length { dst, coll=ra }
1025                let ptr = regs[ra].idx();
1026                let h = heap[ptr].sm();
1027                regs[dst] = RegVal::Small(if h & MAP_TAG != 0 { h & !MAP_TAG } else { h });
1028                pc += 1;
1029            }
1030            52 => {
1031                let ptr = heap.len();
1032                heap.push(RegVal::Small(MAP_TAG));
1033                regs[dst] = RegVal::Small(ptr as i64);
1034                pc += 1;
1035            } // NewEmptyMap — tagged, 0 pairs
1036            64 => {
1037                // FormatValue { dst, src=ra } — the decimal string of regs[ra]
1038                // The full BigInt decimal (exact for a `Big` value too) — byte-identical to
1039                // `logicaffeine_base::BigInt`'s `Display`, the same string the VM renders in interpolation.
1040                let s = match &regs[ra] {
1041                    RegVal::Small(v) => v.to_string(),
1042                    RegVal::Big(b) => b.to_string(),
1043                };
1044                let ptr = heap.len();
1045                heap.push(RegVal::Small(s.chars().count() as i64)); // Text ≡ char-code list [len, chars…]
1046                for ch in s.chars() {
1047                    heap.push(RegVal::Small(i64::from(ch as u32)));
1048                }
1049                regs[dst] = RegVal::Small(ptr as i64);
1050                pc += 1;
1051            }
1052            225 => {
1053                // FormatValueFloat { dst, src=ra, prec=rb } — the fixed-precision decimal string of fregs[ra]
1054                // A float `Show "{f:.N}"` renders `fregs[ra]` to `rb` decimal places EXACTLY as the VM does
1055                // (`crate::semantics::format::apply_format_spec` on a `.N` spec = `format!("{:.prec$}", f, prec)`),
1056                // then lays the UTF-8 bytes out as a Text char-code list `[len, chars…]` on the heap — the same
1057                // `[len, codepoints…]` shape `TextConst`/op 64/Concat use, so a downstream `ShowText` (op 217)
1058                // decodes it byte-identically. The digits/sign/rounding come from Rust's `f64` formatter (the
1059                // single float-display authority the VM shares), so the model string == the VM's `Show` line.
1060                let s = format!("{:.prec$}", fregs[ra], prec = rb);
1061                let ptr = heap.len();
1062                heap.push(RegVal::Small(s.chars().count() as i64)); // Text ≡ char-code list [len, chars…]
1063                for ch in s.chars() {
1064                    heap.push(RegVal::Small(i64::from(ch as u32)));
1065                }
1066                regs[dst] = RegVal::Small(ptr as i64);
1067                pc += 1;
1068            }
1069            63 => {
1070                // Contains { dst, coll=ra, value=rb }
1071                let ptr = regs[ra].idx();
1072                let target = regs[rb].clone();
1073                let mut found = 0i64;
1074                if heap[ptr].sm() & MAP_TAG != 0 {
1075                    // MAP: membership tests the KEYS (int keys)
1076                    let np = (heap[ptr].sm() & !MAP_TAG) as usize;
1077                    for i in 0..np {
1078                        if heap[ptr + 1 + 2 * i] == target {
1079                            found = 1;
1080                            break;
1081                        }
1082                    }
1083                } else {
1084                    // LIST/SET: membership tests the elements
1085                    let len = heap[ptr].sm() as usize;
1086                    for k in 1..=len {
1087                        if heap[ptr + k] == target {
1088                            found = 1;
1089                            break;
1090                        }
1091                    }
1092                }
1093                regs[dst] = RegVal::Small(found);
1094                pc += 1;
1095            }
1096            70 => {
1097                // ListPop { dst, list=ra }
1098                let ptr = regs[ra].idx();
1099                let len = heap[ptr].sm() as usize;
1100                regs[dst] = heap[ptr + len].clone(); // last element
1101                heap[ptr] = RegVal::Small((len - 1) as i64);
1102                pc += 1;
1103            }
1104            74 => {
1105                // NewStruct { dst, num_fields=rb } — record: [n, f0..f_{n-1}]
1106                let n = rb;
1107                let ptr = heap.len();
1108                heap.push(RegVal::Small(n as i64));
1109                for _ in 0..n {
1110                    heap.push(RegVal::Small(0));
1111                }
1112                regs[dst] = RegVal::Small(ptr as i64);
1113                pc += 1;
1114            }
1115            75 | 84 => {
1116                // StructInsert { obj=dst, value=ra, field=rb } / CrdtResolve (register last-write = field overwrite, single-replica)
1117                let ptr = regs[dst].idx();
1118                heap[ptr + 1 + rb] = regs[ra].clone();
1119                pc += 1;
1120            }
1121            76 => {
1122                // GetField { dst, obj=ra, field=rb }
1123                let ptr = regs[ra].idx();
1124                regs[dst] = heap[ptr + 1 + rb].clone();
1125                pc += 1;
1126            }
1127            57 | 58 => {
1128                // SetIndex/SetIndexUnchecked { coll=dst, index/key=ra, value=rb }
1129                let ptr = regs[dst].idx();
1130                if heap[ptr].sm() & MAP_TAG != 0 {
1131                    // MAP: update the key's value, or append a new pair
1132                    let np = (heap[ptr].sm() & !MAP_TAG) as usize;
1133                    let key = regs[ra].idx();
1134                    let mut found = None;
1135                    for i in 0..np {
1136                        if key_eq(&heap, key, heap[ptr + 1 + 2 * i].idx()) {
1137                            found = Some(i);
1138                            break;
1139                        }
1140                    }
1141                    match found {
1142                        Some(i) => heap[ptr + 2 + 2 * i] = regs[rb].clone(),
1143                        None => {
1144                            // append a new `(key_ptr, value)` pair
1145                            // A map is a CONTIGUOUS block `[header, k0,v0, …]`; appending in place is only sound
1146                            // when the block is on top of the heap. The string-key path INTERLEAVES a fresh
1147                            // `TextConst` key char-list before each `Set item "k" of m`, so the map is usually
1148                            // NOT on top — a bare push would scatter the pair past unrelated cells and the
1149                            // `ptr+1+2i` addressing would read garbage (the read returns 0). COPY-ON-WRITE the
1150                            // whole map to a fresh contiguous block (header + all `2*np` pair cells) and repoint
1151                            // `m`, exactly as ListPush COWs a non-top list; when the map IS on top this still
1152                            // appends contiguously (the copy is a no-op relocation).
1153                            if ptr + 1 + 2 * np == heap.len() {
1154                                // on top: grow in place
1155                                heap.push(regs[ra].clone());
1156                                heap.push(regs[rb].clone());
1157                                heap[ptr] = RegVal::Small(MAP_TAG | ((np + 1) as i64));
1158                            } else {
1159                                // else COW to a fresh contiguous block
1160                                let newptr = heap.len();
1161                                heap.push(RegVal::Small(MAP_TAG | ((np + 1) as i64))); // header (with the +1 pair)
1162                                for k in 1..=(2 * np) {
1163                                    let v = heap[ptr + k].clone();
1164                                    heap.push(v);
1165                                }
1166                                heap.push(regs[ra].clone());
1167                                heap.push(regs[rb].clone());
1168                                regs[dst] = RegVal::Small(newptr as i64);
1169                            }
1170                        }
1171                    }
1172                } else {
1173                    // LIST: positional (1-based)
1174                    heap[ptr + regs[ra].idx()] = regs[rb].clone();
1175                }
1176                pc += 1;
1177            }
1178            224 => {
1179                // MapSetIndexInt { map=dst, key=ra, value=rb } — set by INTEGER key
1180                // `Set item k of m to v` for a `Map of Int to …`: find the key cell by a direct integer `==` and
1181                // overwrite its value, else append the pair `[key, value]` (the map is on top of the heap so the
1182                // append grows it in place) and bump the header's pair count. Mirrors the string-key SetIndex, but
1183                // the key cell holds the int key itself. The encoder emits this only for a statically non-Text key.
1184                let ptr = regs[dst].idx();
1185                let np = (heap[ptr].sm() & !MAP_TAG) as usize;
1186                let key = regs[ra].clone();
1187                let mut found = None;
1188                for i in 0..np {
1189                    if heap[ptr + 1 + 2 * i] == key {
1190                        found = Some(i);
1191                        break;
1192                    }
1193                }
1194                match found {
1195                    Some(i) => heap[ptr + 2 + 2 * i] = regs[rb].clone(),
1196                    None => {
1197                        // Same CONTIGUITY rule as the string-key SetIndex: append in place only when the map is
1198                        // on top of the heap, else COW the whole block. An int key rarely allocates (so the map
1199                        // usually stays on top), but a `Map of Int to <heap value>` can interleave allocations —
1200                        // the COW keeps the pair block contiguous so `ptr+1+2i` addressing stays sound.
1201                        if ptr + 1 + 2 * np == heap.len() {
1202                            // on top: grow in place
1203                            heap.push(key);
1204                            heap.push(regs[rb].clone());
1205                            heap[ptr] = RegVal::Small(MAP_TAG | ((np + 1) as i64));
1206                        } else {
1207                            // else COW to a fresh contiguous block
1208                            let newptr = heap.len();
1209                            heap.push(RegVal::Small(MAP_TAG | ((np + 1) as i64)));
1210                            for k in 1..=(2 * np) {
1211                                let v = heap[ptr + k].clone();
1212                                heap.push(v);
1213                            }
1214                            heap.push(key);
1215                            heap.push(regs[rb].clone());
1216                            regs[dst] = RegVal::Small(newptr as i64);
1217                        }
1218                    }
1219                }
1220                pc += 1;
1221            }
1222            33 | 32 => {
1223                // SeqConcat / Concat (Text ≡ char-code list) — fresh lhs ++ rhs
1224                let lp = regs[ra].idx();
1225                let rp = regs[rb].idx();
1226                let ll = heap[lp].sm() as usize;
1227                let rl = heap[rp].sm() as usize;
1228                let ptr = heap.len();
1229                heap.push(RegVal::Small((ll + rl) as i64));
1230                for k in 1..=ll {
1231                    let v = heap[lp + k].clone();
1232                    heap.push(v);
1233                }
1234                for k in 1..=rl {
1235                    let v = heap[rp + k].clone();
1236                    heap.push(v);
1237                }
1238                regs[dst] = RegVal::Small(ptr as i64);
1239                pc += 1;
1240            }
1241            66 => {
1242                // DeepClone { dst, src=ra } — independent copy
1243                // A MAP's header carries MAP_TAG in its high bits, so its cell COUNT is `1 + 2*npairs` (key/value
1244                // pairs), not `heap[sp]` — cloning it as a plain list would read the tagged header as a giant
1245                // length. Copy the header VERBATIM (preserving the tag) and the `2*npairs` key/value cells; a
1246                // plain list/set/struct clones `heap[sp]` cells as before. The isolation pass emits this to keep
1247                // value semantics when a map is mutated in a loop (`Set item k of m`), so a map clone must survive.
1248                // A `Seq of Float`'s element PAYLOADS ride the parallel `fheap` plane at the same slots, so a deep
1249                // copy must carry BOTH planes (mirroring FListPush's two-plane COW) — copying only `heap` clones
1250                // the structure but leaves the cloned block's float payloads 0.0, silently zeroing a float seq
1251                // passed into (and mutated inside) a function. `fheap` stays length-aligned with `heap` (the
1252                // bottom-of-loop resize), so we push a matching float cell for every int cell.
1253                let sp = regs[ra].idx();
1254                let hdr = heap[sp].sm();
1255                let cells = if hdr & MAP_TAG != 0 {
1256                    2 * (hdr & !MAP_TAG) as usize
1257                } else {
1258                    hdr as usize
1259                };
1260                let ptr = heap.len();
1261                heap.push(RegVal::Small(hdr));
1262                fheap.push(fheap[sp]);
1263                for k in 1..=cells {
1264                    let v = heap[sp + k].clone();
1265                    let f = fheap[sp + k];
1266                    heap.push(v);
1267                    fheap.push(f);
1268                }
1269                regs[dst] = RegVal::Small(ptr as i64);
1270                pc += 1;
1271            }
1272            85 => {
1273                iters.push((regs[ra].idx(), 1));
1274                pc += 1;
1275            } // IterPrepare { iterable=ra }
1276            86 => {
1277                // IterNext { dst, exit:16 } — exit_lo=byte2, exit_hi=byte3
1278                let exit = (((instr >> 8) & 0xff) | ((instr & 0xff) << 8)) as usize;
1279                let (ptr, cursor) = *iters.last().unwrap();
1280                let len = heap[ptr].sm() as usize;
1281                if cursor > len {
1282                    pc = exit; // exhausted → the loop's exit (IterPop)
1283                } else {
1284                    regs[dst] = heap[ptr + cursor].clone();
1285                    iters.last_mut().unwrap().1 = cursor + 1;
1286                    pc += 1;
1287                }
1288            }
1289            231 => {
1290                // FIterNext { dst, exit:16 } — float-seq iteration: read the f64 PAYLOAD off `fheap` into `fregs[dst]`
1291                // A `Repeat for v in xs` over a `Seq of Float` iterates the SAME `[length, …]` int structure,
1292                // but each element's value rides the parallel `fheap` plane. This mirrors op 86 (IterNext) but
1293                // reads `fheap[ptr+cursor]` into the float bank, so the loop variable `v` is a float scalar and
1294                // a downstream `s + v` lowers to the FPU — byte-identical to the VM's f64 iteration.
1295                let exit = (((instr >> 8) & 0xff) | ((instr & 0xff) << 8)) as usize;
1296                let (ptr, cursor) = *iters.last().unwrap();
1297                let len = heap[ptr].sm() as usize;
1298                if cursor > len {
1299                    pc = exit;
1300                } else {
1301                    fregs[dst] = fheap[ptr + cursor];
1302                    iters.last_mut().unwrap().1 = cursor + 1;
1303                    pc += 1;
1304                }
1305            }
1306            87 => {
1307                iters.pop();
1308                pc += 1;
1309            } // IterPop
1310            45 => {
1311                let idx = ((instr >> 8) & 0xff) as usize;
1312                regs[dst] = globals[idx].clone();
1313                pc += 1;
1314            } // GlobalGet { dst, idx=a-byte }
1315            46 => {
1316                globals[dst] = regs[ra].clone();
1317                pc += 1;
1318            } // GlobalSet { idx=dst-field, src=ra }
1319            // ── CONCURRENCY coprocessor: FIFO channels (the deterministic NoC) ─────────────
1320            // A channel is a fixed 8-slot ring buffer on the heap: `[head, count, s0..s7]`. Send enqueues
1321            // at `(head+count) mod 8`, Recv dequeues at `head` — FIFO order, exactly the VM's deterministic
1322            // pipe semantics for a single task (buffered send-then-receive).
1323            92 => {
1324                // ChanNew { dst, cap ignored }
1325                let ptr = heap.len();
1326                heap.push(RegVal::Small(0));
1327                heap.push(RegVal::Small(0)); // head, count
1328                for _ in 0..8 {
1329                    heap.push(RegVal::Small(0));
1330                } // 8 ring slots
1331                regs[dst] = RegVal::Small(ptr as i64);
1332                pc += 1;
1333            }
1334            93 => {
1335                // ChanSend { chan=dst, val=ra } — enqueue
1336                let ptr = regs[dst].idx();
1337                let head = heap[ptr].sm() as usize;
1338                let count = heap[ptr + 1].sm() as usize;
1339                let slot = (head + count) % 8;
1340                heap[ptr + 2 + slot] = regs[ra].clone();
1341                heap[ptr + 1] = RegVal::Small((count + 1) as i64);
1342                pc += 1;
1343            }
1344            94 => {
1345                // ChanRecv { dst, chan=ra } — dequeue (FIFO)
1346                let ptr = regs[ra].idx();
1347                let head = heap[ptr].sm() as usize;
1348                let count = heap[ptr + 1].sm() as usize;
1349                regs[dst] = heap[ptr + 2 + head].clone();
1350                heap[ptr] = RegVal::Small(((head + 1) % 8) as i64);
1351                heap[ptr + 1] = RegVal::Small((count - 1) as i64);
1352                pc += 1;
1353            }
1354            95 => {
1355                // ChanTrySend { dst=success, chan=ra, val=rb } — non-blocking
1356                let ptr = regs[ra].idx();
1357                let head = heap[ptr].sm() as usize;
1358                let count = heap[ptr + 1].sm() as usize;
1359                if count < 8 {
1360                    heap[ptr + 2 + (head + count) % 8] = regs[rb].clone();
1361                    heap[ptr + 1] = RegVal::Small((count + 1) as i64);
1362                    regs[dst] = RegVal::Small(1); // sent
1363                } else {
1364                    regs[dst] = RegVal::Small(0); // channel full
1365                }
1366                pc += 1;
1367            }
1368            96 => {
1369                // ChanTryRecv { dst=value, chan=ra } — non-blocking
1370                let ptr = regs[ra].idx();
1371                let head = heap[ptr].sm() as usize;
1372                let count = heap[ptr + 1].sm() as usize;
1373                if count > 0 {
1374                    regs[dst] = heap[ptr + 2 + head].clone();
1375                    heap[ptr] = RegVal::Small(((head + 1) % 8) as i64);
1376                    heap[ptr + 1] = RegVal::Small((count - 1) as i64);
1377                } else {
1378                    regs[dst] = RegVal::Small(0); // nothing
1379                }
1380                pc += 1;
1381            }
1382            88 => {
1383                pc += 1;
1384            } // Sleep { duration } — deterministic scheduler no-op
1385            72 | 73 => {
1386                regs[dst] = RegVal::Small(0);
1387                pc += 1;
1388            } // LoadToday / LoadNow — placeholder epoch (a real chip reads an RTC)
1389            // Value-transparent metadata ops: EnsureOwned (ownership), RegionBoundsGuard (hoist marker),
1390            // Obs (observability emit) — the interpreter treats them as no-ops for VALUES, so the chip does too.
1391            // Networking (105 NetConnect / 106 NetListen / 107 NetSend / 108 NetStream / 111 NetSync):
1392            // on the single-node deterministic chip there is no peer or wire, so — exactly like the VM's
1393            // documented offline model (tw==VM-net==AOT) — they are no-ops. NetSync leaves its synced
1394            // variable unchanged (no peer to merge). The real NIC/relay is an off-chip transport.
1395            7 | 61 | 112 | 105 | 106 | 108 | 111 => {
1396                pc += 1;
1397            } // EnsureOwned / RegionBoundsGuard / Obs / offline Net no-ops (Connect/Listen/Stream/Sync)
1398            110 => {
1399                regs[dst] = RegVal::Small(peer_seq);
1400                peer_seq += 1;
1401                pc += 1;
1402            } // NetMakePeer { dst, addr } — a fresh offline peer key (identity for the loopback inbox)
1403            107 => {
1404                inbox.push((regs[ra].clone(), regs[rb].clone()));
1405                pc += 1;
1406            } // NetSend { to=ra, msg=rb } — PUBLISH (peer key, message) to the loopback inbox
1407            109 => {
1408                // NetAwait { dst, from=ra } — DRAIN the inbox entry keyed by the peer, delivering its message
1409                let want = regs[ra].clone();
1410                if let Some(i) = inbox.iter().position(|(k, _)| *k == want) {
1411                    regs[dst] = inbox.remove(i).1;
1412                }
1413                pc += 1;
1414            }
1415            98 => {
1416                // Spawn { entry=target, args_start=ra, arg_count=rb } — deterministic sync launch (fire-and-forget; pairs with ReturnNothing)
1417                // Launch runs the task SYNCHRONOUSLY (deterministic offline semantics), so a spawn is a
1418                // fire-and-forget Call: the launched body runs in a FRESH frame with R[args_start..+arg_count]
1419                // window-copied into r0.. (exactly the Call convention), its channel side-effects persist on
1420                // the shared heap, and its `ReturnNothing` pops back to Main. This matches the VM's windowed
1421                // task-arg passing (the arg — a channel handle — reaches the body's parameter at its base r0).
1422                let arg_cnt = rb;
1423                let mut callee: [RegVal; 256] = std::array::from_fn(|_| RegVal::Small(0));
1424                let mut callee_f = [0.0f64; 256];
1425                for k in 0..arg_cnt {
1426                    callee[k] = regs[ra + k].clone();
1427                    callee_f[k] = fregs[ra + k];
1428                }
1429                frames.push((regs, fregs, pc + 1, 0));
1430                regs = callee;
1431                fregs = callee_f;
1432                pc = target;
1433            }
1434            99 => {
1435                // SpawnHandle { handle=dst, entry=a-byte } — sync launch + bind a handle
1436                let entry = ((instr >> 8) & 0xff) as usize;
1437                regs[dst] = RegVal::Small(0); // the task handle (placeholder; the frame preserves it across the sync run)
1438                frames.push((regs.clone(), fregs, pc + 1, 0));
1439                pc = entry;
1440            }
1441            101 => {
1442                pc += 1;
1443            } // TaskAbort { handle } — no-op (the deterministic task already ran to completion)
1444            100 => {
1445                pc += 1;
1446            } // TaskAwait { dst, handle } — the instant join: the sync child already completed at spawn, so control passes through (the VM delivers `Nothing` as the result — task returns aren't propagated — so the result reg is not a meaningful observable)
1447            97 => {
1448                pc += 1;
1449            } // ChanClose { chan } — sets a closed flag no receive path reads; output-transparent
1450            44 => {
1451                // CheckPolicy { subject=ra, cap field=rb } — capability engine (boolean-capability class)
1452                // The policy grants iff the subject's capability field is truthy; else the check TRAPS
1453                // (`Security Check Failed`) — enforcing denial, never a no-op. The policy compiler resolves
1454                // the predicate to the capability field index (rb).
1455                if heap[regs[ra].idx() + 1 + rb].sm() != 0 {
1456                    pc += 1;
1457                } else {
1458                    trap = Some(false);
1459                    break;
1460                }
1461            }
1462            38 => {
1463                // CallBuiltin { dst, args_start=ra, builtin=rb } — kernel-builtin coprocessor
1464                // The builtin ORDINAL rides the b-byte (a chip-internal numbering, NOT the BuiltinId
1465                // discriminant — the encoder's `builtin_chip_ordinal` is the single source of truth,
1466                // mirrored here). Two-arg ops read the second operand from regs[args_start+1]. The WORD
1467                // coprocessor ordinals (30/31/32) compute over the register's 32-bit pattern AS UNSIGNED:
1468                // a rotate is `rotate_left/right`, a logical shift zero-fills the vacated high bits
1469                // (distinct from the ALU's ARITHMETIC `Shr`). Bit-exact to `logicaffeine_base::Word32`.
1470                let arg1 = regs[(ra + 1) & 0xff].clone();
1471                let src = regs[ra].clone();
1472                // FLOAT-plane ordinals (36..=41) read `fregs` — the operand was proven float by the type
1473                // oracle. `abs`/`min`/`max` on floats write `fregs[dst]` (a float scalar); `floor`/`ceil`/
1474                // `round` write `regs[dst]` (an INT result: `f.floor() as i64`, round-half-away). All are
1475                // byte-identical to `call_builtin`'s float arms. They `continue` past the integer store below.
1476                if (36..=41).contains(&rb) {
1477                    let a = fregs[ra];
1478                    let b = fregs[(ra + 1) & 0xff];
1479                    match rb {
1480                        36 => fregs[dst] = a.abs(),  // FAbs  (VM: `f.abs()`)
1481                        37 => fregs[dst] = a.min(b), // FMin  (VM: `x.min(y)`)
1482                        38 => fregs[dst] = a.max(b), // FMax  (VM: `x.max(y)`)
1483                        39 => regs[dst] = RegVal::Small(a.floor() as i64), // Floor → Int
1484                        40 => regs[dst] = RegVal::Small(a.ceil() as i64), // Ceil  → Int
1485                        _ => regs[dst] = RegVal::Small(a.round() as i64), // Round → Int (round-half-away)
1486                    }
1487                    pc += 1;
1488                    fheap.resize(heap.len(), 0.0);
1489                    continue;
1490                }
1491                regs[dst] = match rb {
1492                    // Abs promotes on `|i64::MIN|` = 2^63 (exact via BigInt), and takes a `Big` value's exact
1493                    // abs — mirroring the VM's `BuiltinId::Abs`.
1494                    5 => match &src {
1495                        RegVal::Small(n) => match n.checked_abs() {
1496                            Some(a) => RegVal::Small(a),
1497                            None => RegVal::from_big(src.to_big().abs()),
1498                        },
1499                        RegVal::Big(b) => RegVal::from_big(b.abs()),
1500                    },
1501                    7 => {
1502                        if regval_cmp(&src, &arg1) != std::cmp::Ordering::Greater {
1503                            src
1504                        } else {
1505                            arg1
1506                        }
1507                    } // Min
1508                    8 => {
1509                        if regval_cmp(&src, &arg1) != std::cmp::Ordering::Less {
1510                            src
1511                        } else {
1512                            arg1
1513                        }
1514                    } // Max
1515                    // `count_ones(n)` — set bits over the full 64-bit two's-complement pattern, EXACTLY the
1516                    // VM's `(n as u64).count_ones()`. A `Big` operand narrows to its low i64 first (a chip
1517                    // register is 64-bit; `count_ones` is defined only over `Int` in `call_builtin`).
1518                    9 => RegVal::Small(i64::from((src.sm() as u64).count_ones())),
1519                    // The WORD32 ordinals operate on the register's low 32-bit pattern (the `Word32` type is
1520                    // ALWAYS 32-bit, independent of the datapath width) and sign-extend the 32-bit result.
1521                    30 => RegVal::Small(i64::from(
1522                        (src.sm() as u32).rotate_left(arg1.sm() as u32) as i32
1523                    )), // BuiltinId::Rotl
1524                    31 => RegVal::Small(i64::from(
1525                        (src.sm() as u32).rotate_right(arg1.sm() as u32) as i32,
1526                    )), // BuiltinId::Rotr
1527                    32 => RegVal::Small(i64::from(
1528                        (src.sm() as u32).wrapping_shr(arg1.sm() as u32) as i32,
1529                    )), // BuiltinId::Word32Shr (LOGICAL)
1530                    _ => RegVal::Small(0),
1531                };
1532                pc += 1;
1533            }
1534            102 => {
1535                sel_arms.push((dst, ra));
1536                pc += 1;
1537            } // SelectArmRecv { chan=dst, var=ra } — register a recv arm
1538            103 => {
1539                sel_arms.push((usize::MAX, ra));
1540                pc += 1;
1541            } // SelectArmTimeout { ticks=ra } — register a timeout arm
1542            104 => {
1543                // SelectWait { dst_arm } — fire first-ready recv, else timeout
1544                let mut fired = usize::MAX;
1545                for (i, &(cr, vr)) in sel_arms.iter().enumerate() {
1546                    if cr != usize::MAX {
1547                        // a recv arm
1548                        let ch = regs[cr].idx();
1549                        if heap[ch + 1].sm() > 0 {
1550                            // channel has a buffered value
1551                            let head = heap[ch].sm() as usize;
1552                            regs[vr] = heap[ch + 2 + head].clone();
1553                            heap[ch] = RegVal::Small(((head + 1) % 8) as i64);
1554                            heap[ch + 1] = RegVal::Small(heap[ch + 1].sm() - 1);
1555                            fired = i;
1556                            break;
1557                        }
1558                    }
1559                }
1560                if fired == usize::MAX {
1561                    // nothing ready → the timeout arm, if any
1562                    for (i, &(cr, _)) in sel_arms.iter().enumerate() {
1563                        if cr == usize::MAX {
1564                            fired = i;
1565                            break;
1566                        }
1567                    }
1568                }
1569                regs[dst] = RegVal::Small(fired as i64);
1570                sel_arms.clear();
1571                pc += 1;
1572            }
1573            // ── CRDT coprocessor: convergent counter ──────────────────────────────────────
1574            // A `Counter` is a plain struct (`[nfields, f0..]`); `Increase c's n by k` accumulates the
1575            // field (`crdt_counter_bump` = field ± amount) and `Merge b into a` joins field-wise —
1576            // `crdt_merge_field(Int,Int)` is wrapping-ADD, so counters converge by summing. Counter
1577            // arithmetic is INTENTIONALLY wrapping (modular, over the cyclic group — see the VM's
1578            // `crdt_counter_bump`/`crdt_merge_field`), so these fields stay `Small` and wrap, NOT promote.
1579            80 => {
1580                // CrdtBump { obj=dst, amount=ra, field=b[3:0], negate=b[4] }
1581                let fb = (instr & 0xff) as usize;
1582                let field = fb & 0xf;
1583                let neg = (fb >> 4) & 1;
1584                let amt = regs[ra].sm();
1585                let ptr = regs[dst].idx();
1586                let delta = if neg == 1 { -amt } else { amt };
1587                heap[ptr + 1 + field] =
1588                    RegVal::Small(heap[ptr + 1 + field].sm().wrapping_add(delta));
1589                pc += 1;
1590            }
1591            81 => {
1592                // CrdtMerge { target=dst, source=ra } — field-wise sum-join
1593                let tp = regs[dst].idx();
1594                let sp = regs[ra].idx();
1595                let nf = heap[sp].sm() as usize; // source field count
1596                for f in 0..nf {
1597                    heap[tp + 1 + f] =
1598                        RegVal::Small(heap[tp + 1 + f].sm().wrapping_add(heap[sp + 1 + f].sm()));
1599                }
1600                pc += 1;
1601            }
1602            113 => {
1603                // CrdtJoin { target=dst, source=ra } — per-replica MAX
1604                let tp = regs[dst].idx(); // the state-based G-Counter lattice join
1605                let sp = regs[ra].idx(); // (idempotent), issued to the crdt_merge block
1606                let nf = heap[sp].sm() as usize; // source lane count (== target's for a G-Counter)
1607                for f in 0..nf {
1608                    heap[tp + 1 + f] =
1609                        RegVal::Small(heap[tp + 1 + f].sm().max(heap[sp + 1 + f].sm()));
1610                }
1611                pc += 1;
1612            }
1613            114 => {
1614                // BigIntWide — the 256-bit lane (kind = dst-byte hi nibble)
1615                use crate::codegen_sva::bignum_rtl::{
1616                    limb_add, limb_lt, limb_sub, wdiv256, wmul256,
1617                };
1618                let kind = ((instr >> 20) & 0xf) as u8; // operation rides the dst byte's high nibble
1619                match kind {
1620                    0 => wide[dst & 7] = [(regs[ra].sm() as u32) as u64, 0, 0, 0], // FromInt: W[dst] = zext(R[a])
1621                    1 => wide[dst & 7] = limb_add(&wide[ra & 7], &wide[rb & 7]).0, // Add   (bigint_alu)
1622                    2 => wide[dst & 7] = limb_sub(&wide[ra & 7], &wide[rb & 7]).0, // Sub   (bigint_alu)
1623                    3 => {
1624                        regs[dst & 0xf] =
1625                            RegVal::Small(i64::from(limb_lt(&wide[ra & 7], &wide[rb & 7])))
1626                    } // Cmp: R[dst] = (W[a] < W[b])
1627                    4 => {
1628                        regs[dst & 0xf] = RegVal::Small(i64::from((wide[ra & 7][0] as u32) as i32))
1629                    } // Low: R[dst] = W[a][31:0] (sign-extended)
1630                    5 => wide[dst & 7] = wdiv256(&wide[ra & 7], &wide[rb & 7]), // Div   (bigint_div)
1631                    6 => wide[dst & 7] = wmul256(&wide[ra & 7], &wide[rb & 7]), // Mul   (bigint_mul composed)
1632                    _ => {}
1633                }
1634                pc += 1;
1635            }
1636            77 => {
1637                // NewInductive { dst, ctor=a-byte, args_start=b[7:4], count=b[3:0] }
1638                let ctor = ((instr >> 8) & 0xff) as i64; // the variant tag (a valid lowering of the VM's ctor id)
1639                let args_start = ((instr >> 4) & 0xf) as usize;
1640                let count = (instr & 0xf) as usize;
1641                let ptr = heap.len();
1642                heap.push(RegVal::Small(ctor)); // record: [tag, payload0, …]
1643                for i in 0..count {
1644                    heap.push(regs[(args_start + i) & 0xff].clone());
1645                }
1646                regs[dst] = RegVal::Small(ptr as i64);
1647                pc += 1;
1648            }
1649            78 => {
1650                // TestArm { dst, target=ra, variant=b-byte } → tag == variant
1651                let variant = (instr & 0xff) as i64;
1652                let ptr = regs[ra].idx();
1653                regs[dst] = RegVal::Small(if heap[ptr].sm() == variant { 1 } else { 0 });
1654                pc += 1;
1655            }
1656            79 => {
1657                // BindArm { dst, target=ra, index=b-byte } → payload[index]
1658                let index = (instr & 0xff) as usize;
1659                let ptr = regs[ra].idx();
1660                regs[dst] = heap[ptr + 1 + index].clone();
1661                pc += 1;
1662            }
1663            51 => {
1664                let ptr = heap.len();
1665                heap.push(RegVal::Small(0));
1666                regs[dst] = RegVal::Small(ptr as i64);
1667                pc += 1;
1668            } // NewEmptySet
1669            55 => {
1670                // SetAdd { set=dst, value=ra } — dedup on insert
1671                let ptr = regs[dst].idx();
1672                let len = heap[ptr].sm() as usize;
1673                let v = regs[ra].clone();
1674                let mut found = false;
1675                for k in 1..=len {
1676                    if heap[ptr + k] == v {
1677                        found = true;
1678                        break;
1679                    }
1680                }
1681                if !found {
1682                    if ptr + 1 + len == heap.len() {
1683                        // on top: append in place
1684                        heap.push(v);
1685                        heap[ptr] = RegVal::Small((len + 1) as i64);
1686                    } else {
1687                        // else copy-on-write (value semantics)
1688                        let newptr = heap.len();
1689                        heap.push(RegVal::Small((len + 1) as i64));
1690                        for k in 1..=len {
1691                            let x = heap[ptr + k].clone();
1692                            heap.push(x);
1693                        }
1694                        heap.push(v);
1695                        regs[dst] = RegVal::Small(newptr as i64);
1696                    }
1697                }
1698                pc += 1;
1699            }
1700            53 => {
1701                // NewRange { dst, start=ra, end=rb } — inclusive [start..=end]
1702                let start = regs[ra].sm();
1703                let end = regs[rb].sm();
1704                let n = if end >= start {
1705                    (end - start + 1) as usize
1706                } else {
1707                    0
1708                };
1709                let ptr = heap.len();
1710                heap.push(RegVal::Small(n as i64));
1711                for i in 0..n {
1712                    heap.push(RegVal::Small(start + i as i64));
1713                }
1714                regs[dst] = RegVal::Small(ptr as i64);
1715                pc += 1;
1716            }
1717            68 => {
1718                // UnionOp { dst, lhs=ra, rhs=rb } — fresh lhs ∪ rhs (dedup)
1719                let lp = regs[ra].idx();
1720                let rp = regs[rb].idx();
1721                let ll = heap[lp].sm() as usize;
1722                let rl = heap[rp].sm() as usize;
1723                let ptr = heap.len();
1724                heap.push(RegVal::Small(0));
1725                let mut count = 0usize;
1726                for k in 1..=ll {
1727                    let v = heap[lp + k].clone();
1728                    heap.push(v);
1729                    count += 1;
1730                }
1731                for k in 1..=rl {
1732                    let v = heap[rp + k].clone();
1733                    let mut found = false;
1734                    for j in 0..count {
1735                        if heap[ptr + 1 + j] == v {
1736                            found = true;
1737                            break;
1738                        }
1739                    }
1740                    if !found {
1741                        heap.push(v);
1742                        count += 1;
1743                    }
1744                }
1745                heap[ptr] = RegVal::Small(count as i64);
1746                regs[dst] = RegVal::Small(ptr as i64);
1747                pc += 1;
1748            }
1749            69 => {
1750                // IntersectOp { dst, lhs=ra, rhs=rb } — fresh lhs ∩ rhs
1751                let lp = regs[ra].idx();
1752                let rp = regs[rb].idx();
1753                let ll = heap[lp].sm() as usize;
1754                let rl = heap[rp].sm() as usize;
1755                let ptr = heap.len();
1756                heap.push(RegVal::Small(0));
1757                let mut count = 0usize;
1758                for k in 1..=ll {
1759                    let v = heap[lp + k].clone();
1760                    let mut in_rhs = false;
1761                    for j in 1..=rl {
1762                        if heap[rp + j] == v {
1763                            in_rhs = true;
1764                            break;
1765                        }
1766                    }
1767                    if in_rhs {
1768                        heap.push(v);
1769                        count += 1;
1770                    }
1771                }
1772                heap[ptr] = RegVal::Small(count as i64);
1773                regs[dst] = RegVal::Small(ptr as i64);
1774                pc += 1;
1775            }
1776            56 => {
1777                // RemoveFrom { collection=dst, value=ra } — drop value, shift down
1778                let ptr = regs[dst].idx();
1779                let len = heap[ptr].sm() as usize;
1780                let want = regs[ra].clone();
1781                let mut idx = 0usize;
1782                for k in 1..=len {
1783                    if heap[ptr + k] == want {
1784                        idx = k;
1785                        break;
1786                    }
1787                }
1788                if idx > 0 {
1789                    for k in idx..len {
1790                        let nv = heap[ptr + k + 1].clone();
1791                        heap[ptr + k] = nv;
1792                    }
1793                    heap[ptr] = RegVal::Small((len - 1) as i64);
1794                }
1795                pc += 1;
1796            }
1797            71 => {
1798                // DestructureTuple { start=dst, src=ra, count=rb } — spread into regs
1799                let sp = regs[ra].idx();
1800                for i in 0..rb {
1801                    regs[(dst + i) & 0xff] = heap[sp + 1 + i].clone();
1802                }
1803                pc += 1;
1804            }
1805            65 => {
1806                // SliceOp { dst, coll=ra, start=b[7:4], end=b[3:0] } — 1-based inclusive
1807                // `items i through j of xs`: the 1-based-inclusive bounds ride the b-byte nibbles (the encoder
1808                // constant-folds them; a runtime/large bound has no chip lowering and is rejected loudly). The
1809                // VM's `collections::slice` is 1-based-inclusive with an EMPTY out-of-range result: `start0 =
1810                // (start-1)` (0-based inclusive), `end` (1-based inclusive == 0-based exclusive); empty when
1811                // `start0 ≥ end` or `end > len`. Copy `[cp+1+start0 ..< cp+1+end]`.
1812                let start = ((instr >> 4) & 0xf) as usize;
1813                let end = (instr & 0xf) as usize;
1814                let cp = regs[ra].idx();
1815                let len = heap[cp].sm() as usize;
1816                let start0 = start.saturating_sub(1);
1817                let (lo, hi) = if start0 < end && end <= len {
1818                    (start0, end)
1819                } else {
1820                    (0, 0)
1821                };
1822                let ptr = heap.len();
1823                heap.push(RegVal::Small((hi - lo) as i64));
1824                for i in lo..hi {
1825                    let v = heap[cp + 1 + i].clone();
1826                    heap.push(v);
1827                }
1828                regs[dst] = RegVal::Small(ptr as i64);
1829                pc += 1;
1830            }
1831            47 => {
1832                // ListPushField { obj=dst, src=ra, field=rb } — push to obj's list field
1833                let obj_ptr = regs[dst].idx();
1834                let list_ptr = heap[obj_ptr + 1 + rb].idx();
1835                let len = heap[list_ptr].sm() as usize;
1836                if list_ptr + 1 + len == heap.len() {
1837                    // list on top: append in place
1838                    heap.push(regs[ra].clone());
1839                    heap[list_ptr] = RegVal::Small((len + 1) as i64);
1840                } else {
1841                    // else copy-on-write, and repoint the field
1842                    let newptr = heap.len();
1843                    heap.push(RegVal::Small((len + 1) as i64));
1844                    for k in 1..=len {
1845                        let v = heap[list_ptr + k].clone();
1846                        heap.push(v);
1847                    }
1848                    heap.push(regs[ra].clone());
1849                    heap[obj_ptr + 1 + rb] = RegVal::Small(newptr as i64);
1850                }
1851                pc += 1;
1852            }
1853            // Div / ExactDiv / Mod / FloorDiv issue to the shared divider (not the ALU) — the
1854            // RTL stalls until `done`; the result is identical, so the model retires it directly. The
1855            // value-integer divider/ALU AUTO-PROMOTE to BigInt on overflow, EXACTLY like the VM (`arith`),
1856            // so the chip differs nowhere on integers — the `width` no longer masks value arithmetic.
1857            12 | 13 | 14 | 15 => {
1858                let r = soft_core_divider_regval(opcode, &regs[ra], &regs[rb]);
1859                regs[dst] = r;
1860                pc += 1;
1861            }
1862            // Pow (op 18): `base ** exp` for a non-negative integer exponent — the sequential-multiply power
1863            // unit (a stall-based FSM in the RTL, mirroring the shared divider), retired directly by the model.
1864            // The accumulator starts at 1 and multiplies by `base` `exp` times through the SAME auto-promoting
1865            // value-integer multiply the ALU uses (`soft_core_alu_regval(11, …)`), so it is BigInt-exact —
1866            // byte-identical to the VM's `int_power`/`BigInt::pow` (`base^0 == 1`; overflow promotes, never
1867            // wraps). A negative exponent errors in the VM (an Int can't hold a fraction), so no differential
1868            // program raises to one; the unsigned loop count reflects that the chip's power unit takes exp ≥ 0.
1869            18 => {
1870                let base = regs[ra].clone();
1871                let exp = regs[rb].sm();
1872                let mut acc = RegVal::Small(1);
1873                let mut k = if exp < 0 { 0 } else { exp };
1874                while k > 0 {
1875                    acc = soft_core_alu_regval(11, &acc, &base);
1876                    k -= 1;
1877                }
1878                regs[dst] = acc;
1879                pc += 1;
1880            }
1881            // ── WORD32 (ℤ/2³²) ARITHMETIC (chip-synthetic 226/227/228) — a Word32 `+`/`-`/`*` wraps in the
1882            // ring, NOT the unbounded value-integer ALU. The register holds the 32-bit pattern; compute on
1883            // the low 32 bits, wrap (`u32::wrapping_op`), and stay `Small` — a Word32 is NEVER a BigInt.
1884            // Bit-exact to the VM's `Word32::add`/`sub`/`mul` (`u32::wrapping_*`). Sign-extended back to i64
1885            // (`as i32 as i64`) so a Word32 whose high bit is set reads as a negative i64 pattern, exactly
1886            // as the 32-bit datapath carried it; `ShowWord32` re-reads the low 32 bits UNSIGNED.
1887            226 => {
1888                regs[dst] = RegVal::Small(i64::from(
1889                    (regs[ra].sm() as u32).wrapping_add(regs[rb].sm() as u32) as i32,
1890                ));
1891                pc += 1;
1892            }
1893            227 => {
1894                regs[dst] = RegVal::Small(i64::from(
1895                    (regs[ra].sm() as u32).wrapping_sub(regs[rb].sm() as u32) as i32,
1896                ));
1897                pc += 1;
1898            }
1899            228 => {
1900                regs[dst] = RegVal::Small(i64::from(
1901                    (regs[ra].sm() as u32).wrapping_mul(regs[rb].sm() as u32) as i32,
1902                ));
1903                pc += 1;
1904            }
1905            233 => {
1906                // Chr { dst, code=ra } — a 1-char Text from a codepoint
1907                // `chr(code)` builds a ONE-character Text — the char-code list `[1, codepoint]` on the heap,
1908                // the SAME shape TextConst/op 64/Concat produce, so a downstream `ShowText` decodes it
1909                // byte-identically to the VM's `RuntimeValue::Text(c.to_string())`. An INVALID codepoint (a
1910                // surrogate 0xD800..=0xDFFF or a value > 0x10FFFF) has no character — the VM's `call_builtin`
1911                // returns `Err` there, aborting the run with the output produced so far standing, so the chip
1912                // TRAPS (halts, pre-trap output stands), byte-identical to the VM's error-path output.
1913                let code = regs[ra].sm();
1914                match u32::try_from(code).ok().and_then(char::from_u32) {
1915                    Some(_) => {
1916                        let ptr = heap.len();
1917                        heap.push(RegVal::Small(1)); // header: a 1-char string
1918                        heap.push(RegVal::Small(code)); // the codepoint (a valid `char`)
1919                        regs[dst] = RegVal::Small(ptr as i64);
1920                        pc += 1;
1921                    }
1922                    None => {
1923                        trap = Some(false);
1924                        break;
1925                    } // invalid codepoint → trap (VM returns Err)
1926                }
1927            }
1928            234 => {
1929                // TextContains { dst, hay=ra, needle=rb } — substring scan
1930                // `contains(haystack, needle)` over two Text char-lists: `regs[dst] = 1` iff `needle` occurs
1931                // as a contiguous run of codepoints in `haystack`, else 0 — byte-identical to the VM's
1932                // `h.contains(n.as_str())` (Rust `str::contains` on the reconstructed strings). The empty
1933                // needle is contained in EVERYTHING (`"".contains` is always true), matching Rust. The scan
1934                // compares CODEPOINTS (the char-list cells), which is exactly Rust's UTF-8 substring test
1935                // since a codepoint match is a byte match for the canonical UTF-8 both sides carry.
1936                let hp = regs[ra].idx();
1937                let np = regs[rb].idx();
1938                let hl = heap[hp].sm() as usize; // haystack char count
1939                let nl = heap[np].sm() as usize; // needle char count
1940                let mut found = 0i64;
1941                if nl == 0 {
1942                    found = 1; // the empty needle is always contained
1943                } else if nl <= hl {
1944                    for start in 0..=(hl - nl) {
1945                        // every window of `nl` chars in the haystack
1946                        let mut all = true;
1947                        for k in 0..nl {
1948                            if heap[hp + 1 + start + k].sm() != heap[np + 1 + k].sm() {
1949                                all = false;
1950                                break;
1951                            }
1952                        }
1953                        if all {
1954                            found = 1;
1955                            break;
1956                        }
1957                    }
1958                }
1959                regs[dst] = RegVal::Small(found);
1960                pc += 1;
1961            }
1962            235 => {
1963                // RepeatSeq { dst, elem=ra, count=rb } — n-slot scalar fill
1964                // `repeatSeq(x, n)` builds a fresh `n`-element Seq, every slot the SAME scalar `regs[ra]`
1965                // (`n ≤ 0` is empty), byte-identical to the VM's `(0..n).map(|_| element.deep_clone())` for a
1966                // SCALAR element (an Int/Word32 register value deep-clones to itself — the encoder only emits
1967                // this op for a provably-scalar element, rejecting a float/reference element inside, since a
1968                // heap-reference element would need per-slot deep copies the flat fill can't express). The
1969                // count is a runtime register value; the chip's fill unit is a sequential state machine (like
1970                // the divider), so the model loops here.
1971                let n = regs[rb].sm().max(0) as usize;
1972                let elem = regs[ra].clone();
1973                let ptr = heap.len();
1974                heap.push(RegVal::Small(n as i64)); // header
1975                for _ in 0..n {
1976                    heap.push(elem.clone());
1977                }
1978                regs[dst] = RegVal::Small(ptr as i64);
1979                pc += 1;
1980            }
1981            236 => {
1982                // ParseInt { dst, text=ra } — decimal char-scan → i64
1983                // `parseInt(text)` reads the Text char-list at `regs[ra]` (`[len, cp0, cp1, …]`) and computes
1984                // the i64 value EXACTLY as the VM's `s.trim().parse::<i64>()`: trim Unicode whitespace off both
1985                // ends, accept an optional single leading `+`/`-`, then require ≥1 ASCII digit, accumulating in
1986                // the NEGATIVE magnitude (`acc = acc*10 − digit`) so `-9223372036854775808` (= i64::MIN) parses
1987                // where its positive twin overflows — the exact boundary Rust's parser draws. A non-numeric,
1988                // empty, or OVERFLOWING input has no i64 value: the VM's `parse` returns `Err`, whose `?` aborts
1989                // the run with the output so far standing, so the chip TRAPS (`break`, pre-trap output stands) —
1990                // byte-identical. The VM does NOT promote an overflow to BigInt; it errors, so neither do we.
1991                let ptr = regs[ra].idx();
1992                let len = heap[ptr].sm() as usize;
1993                // Reconstruct the codepoint slice, then trim by Unicode whitespace (Rust's `str::trim`).
1994                let cps: Vec<u32> = (0..len).map(|k| heap[ptr + 1 + k].sm() as u32).collect();
1995                let is_ws = |cp: u32| char::from_u32(cp).map_or(false, |c| c.is_whitespace());
1996                let mut lo = 0usize;
1997                let mut hi = cps.len();
1998                while lo < hi && is_ws(cps[lo]) {
1999                    lo += 1;
2000                }
2001                while hi > lo && is_ws(cps[hi - 1]) {
2002                    hi -= 1;
2003                }
2004                let body = &cps[lo..hi];
2005                // Optional single leading sign, then the digit run (matching `i64::from_str_radix(_, 10)`).
2006                let (neg, digits) = match body.first().copied() {
2007                    Some(c) if c == '-' as u32 => (true, &body[1..]),
2008                    Some(c) if c == '+' as u32 => (false, &body[1..]),
2009                    _ => (false, body),
2010                };
2011                // Accumulate as the NEGATIVE magnitude; any non-digit char or an i64 overflow → trap.
2012                let mut acc: Option<i64> = Some(0);
2013                let mut saw_digit = false;
2014                for &cp in digits {
2015                    if !(('0' as u32)..=('9' as u32)).contains(&cp) {
2016                        acc = None;
2017                        break;
2018                    }
2019                    saw_digit = true;
2020                    let d = (cp - '0' as u32) as i64;
2021                    acc = acc
2022                        .and_then(|a| a.checked_mul(10))
2023                        .and_then(|a| a.checked_sub(d));
2024                    if acc.is_none() {
2025                        break;
2026                    }
2027                }
2028                // Empty input (no digits at all) is an error too — Rust's parser rejects `""`/`"-"`/`"+"`.
2029                let parsed = if !saw_digit {
2030                    None
2031                } else {
2032                    acc.and_then(|a| if neg { Some(a) } else { a.checked_neg() })
2033                };
2034                match parsed {
2035                    Some(v) => {
2036                        regs[dst] = RegVal::Small(v);
2037                        pc += 1;
2038                    }
2039                    None => {
2040                        trap = Some(false);
2041                        break;
2042                    } // non-numeric / empty / overflow → trap (VM returns Err)
2043                }
2044            }
2045            237 => {
2046                // TextBytes { dst, text=ra } — UTF-8 encode a Text char-list
2047                // `text_bytes(text)` reads the char-list at `regs[ra]` (`[len, cp0, cp1, …]`) and builds a fresh
2048                // `Seq of Int` `[n, b0, b1, …]` of its UTF-8 bytes, byte-identical to the VM's
2049                // `bytes_to_seq(s.as_bytes())`. Each codepoint encodes by its range ([`utf8_of_codepoint`], the
2050                // RTL's `tbx` encoder); the bytes follow the header in order, and the header, written last,
2051                // carries the total byte count.
2052                let ptr = regs[ra].idx();
2053                let len = heap[ptr].sm() as usize;
2054                let base = heap.len();
2055                heap.push(RegVal::Small(0));
2056                for k in 0..len {
2057                    let (bytes, n) = utf8_of_codepoint(heap[ptr + 1 + k].sm() as u32);
2058                    for &b in &bytes[..n] {
2059                        heap.push(RegVal::Small(i64::from(b)));
2060                    }
2061                }
2062                heap[base] = RegVal::Small((heap.len() - base - 1) as i64);
2063                regs[dst] = RegVal::Small(base as i64);
2064                pc += 1;
2065            }
2066            _ => {
2067                let r = soft_core_alu_regval(opcode, &regs[ra], &regs[rb]);
2068                regs[dst] = r;
2069                pc += 1;
2070            }
2071        }
2072        // FLOAT-HEAP PLANE INVARIANT: keep `fheap` the SAME LENGTH as `heap` after every op. Any op that
2073        // grew `heap` (an int list/map/range/struct/…) leaves a matching `0.0` float cell — the float-list
2074        // ops (213/214/215) keep their own float cells aligned as they push, so this only backfills the
2075        // integer-structure growth. Mirrors the RTL `fdmem` bump tracking `dmem`.
2076        fheap.resize(heap.len(), 0.0);
2077    }
2078    (out, trap)
2079}
2080
2081/// The UTF-8 encoding of one codepoint by its range — one byte below 0x80, two below 0x800, three below
2082/// 0x10000, else four — returned as the bytes and their count. On every Unicode scalar value this is
2083/// exactly `char::encode_utf8`; it is written by range because it is the RTL's encoder too (`tbx` in
2084/// `soft_core.v`), so the model and the silicon share one spec rather than one calling the standard library.
2085fn utf8_of_codepoint(cp: u32) -> ([u8; 4], usize) {
2086    let cont = |shift: u32| 0x80 | ((cp >> shift) & 0x3F) as u8;
2087    if cp < 0x80 {
2088        ([cp as u8, 0, 0, 0], 1)
2089    } else if cp < 0x800 {
2090        ([0xC0 | (cp >> 6) as u8, cont(0), 0, 0], 2)
2091    } else if cp < 0x1_0000 {
2092        ([0xE0 | (cp >> 12) as u8, cont(6), cont(0), 0], 3)
2093    } else {
2094        (
2095            [0xF0 | ((cp >> 18) & 0x07) as u8, cont(12), cont(6), cont(0)],
2096            4,
2097        )
2098    }
2099}
2100
2101/// The integer-differential view of [`run_soft_core_typed`]: the `Show` sequence as raw `i64` values.
2102/// The pure integer programs (the hand-assembled ≡VM differential, the testbench emitter, the silicon
2103/// demo) never emit a float `Show`, so at the 32-bit width this stays value-identical to the pre-FPU
2104/// model (a 32-bit result carried in the low bits of an `i64`). A float program uses
2105/// [`run_soft_core_typed`] + [`ShowVal::fmt`] instead (panics loudly here if one leaks a float).
2106pub fn run_soft_core(imem: &[u32], max_cycles: usize) -> Vec<i64> {
2107    run_soft_core_w(imem, max_cycles, 32)
2108}
2109
2110/// The WIDTH-PARAMETRIC integer-differential view of [`run_soft_core_typed_w`]: the `Show` sequence as raw
2111/// `i64` values MASKED to the datapath `width` — the fixed-width tape-out core's OUTPUT REGISTER view
2112/// (`width == 32` truncates each value to i32, `width == 64` is the full i64 pattern). This is DISTINCT
2113/// from [`run_soft_core_typed_w`], whose `ShowVal` carries the UNBOUNDED value (VM-exact via BigInt): the
2114/// typed path is the chip that "differs nowhere from the VM"; this raw view models what a `width`-bit
2115/// hardware output register would show if you read the low bits, so the 32-bit tape-out core's wrap is
2116/// still observable here. A value that overflowed to `Big` contributes its low-64-bit two's-complement
2117/// pattern, then sign-extended to `width`. (Float/Text/Word32 `Show`s panic — the pure-integer callers
2118/// never emit them.)
2119pub fn run_soft_core_w(imem: &[u32], max_cycles: usize, width: u32) -> Vec<i64> {
2120    run_soft_core_typed_w(imem, max_cycles, width)
2121        .iter()
2122        .map(|sv| sign_extend_to_width(showval_low_i64(sv), width))
2123        .collect()
2124}
2125
2126/// The low-64-bit two's-complement pattern of an integer `Show` — the `i64` for a `Small`, the low limb
2127/// (with sign) for a `Big`. The width-masked raw view ([`run_soft_core_w`]) reads this; a Float/Text/
2128/// Word32 `Show` panics (the pure-integer differential/testbench callers never emit one).
2129fn showval_low_i64(sv: &ShowVal) -> i64 {
2130    match sv {
2131        ShowVal::Int(v) => *v,
2132        ShowVal::BigInt(b) => {
2133            // The low 64 bits as a two's-complement i64: reduce the exact value mod 2^64 via the
2134            // signed remainder against 2^64 (an over-i64 `Big` never comes from the 32-bit oracle, so
2135            // this only serves the width-64 raw view of a genuinely huge value).
2136            let mask = BigInt::from_u64(u64::MAX).add(&BigInt::from_i64(1)); // 2^64
2137            let (_, r) = b.div_rem(&mask).expect("2^64 is nonzero");
2138            // `r` carries the dividend's sign and |r| < 2^64; fold to the u64 bit pattern, then i64.
2139            let (neg, bytes) = r.to_le_bytes();
2140            let mut limb = [0u8; 8];
2141            for (i, byte) in bytes.iter().take(8).enumerate() {
2142                limb[i] = *byte;
2143            }
2144            let u = u64::from_le_bytes(limb);
2145            let u = if neg { u.wrapping_neg() } else { u };
2146            u as i64
2147        }
2148        ShowVal::Float(f) => panic!("integer soft-core run emitted a float Show ({f})"),
2149        ShowVal::Text(s) => panic!("integer soft-core run emitted a text Show ({s:?})"),
2150        ShowVal::Word32(w) => panic!("integer soft-core run emitted a word32 Show ({w})"),
2151    }
2152}
2153
2154#[cfg(test)]
2155#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/soft_core_model/width_toggle_tests.rs"]
2156mod width_toggle_tests;
2157
2158#[cfg(test)]
2159#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/soft_core_model/text_bytes_tests.rs"]
2160mod text_bytes_tests;