diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02159872..00f177a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,6 +266,29 @@ jobs: 1 python3 tests/xprop_cosim/check.py target/test-out/ci_sim_2state.vcd two-state + # Register value-injection regression (#108). A/B on the same SRAM-less + # design: cosim --xprop with NO reg_init leaves the unreset counter X + # (q_unreset stays x); cosim --xprop WITH a reg_init $deposit on + # `unreset_count` clears the power-up X so q_unreset reads known 0/1. + # This is the maintainer's #102 fix-pattern in miniature (deposit on an + # unreset register → downstream definite) and the first cosim run of a + # zero-SRAM design (guards the nil-MTLBuffer fix). + - name: Register value-injection demo — cosim (#108) + run: | + set -e + cargo run --release --features metal --bin jacquard -- cosim \ + tests/xprop_cosim/xprop_demo_synth.gv \ + --config tests/xprop_cosim/sim_config.json \ + --output-vcd target/test-out/ci_cosim_noreginit.vcd \ + --max-clock-edges 200 --xprop + python3 tests/xprop_cosim/check.py target/test-out/ci_cosim_noreginit.vcd xprop + cargo run --release --features metal --bin jacquard -- cosim \ + tests/xprop_cosim/xprop_demo_synth.gv \ + --config tests/xprop_cosim/sim_config_reginit.json \ + --output-vcd target/test-out/ci_cosim_reginit.vcd \ + --max-clock-edges 200 --xprop + python3 tests/xprop_cosim/check.py target/test-out/ci_cosim_reginit.vcd reg-init + - name: Run Metal simulation with timing (inv_chain_pnr) run: | set -o pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 37df4d8e..6378ad10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ the public contracts in `docs/release-process.md` follow stricter rules). ## [Unreleased] +### Added + +- **`reg_init` register value-injection for cosim** (#108). A new + `reg_init` array in the testbench JSON deposits a definite value into + chosen registers at tick 0 with `$deposit` semantics — the seed clears + the power-up X-mask, then the design's own logic drives the register + normally (NOT `force`, which would pin a CDC crossing register and write + zeros across the handshake). Each entry is `{ "name", "value", "width" }`; + a multi-bit register resolves `name[0]`..`name[width-1]` independently. + This is the register sibling of `sram_init` and the fix path for + X-poisoned unreset CDC launch registers (#102): depositing on the launch + flops lets X-aware cosim of debug-loaded firmware proceed. Composes with + the x-assert detection work (#106). Regression: `tests/xprop_cosim/` + `reg-init` mode (cosim A/B — deposit clears the X that `xprop` mode + requires to persist). + +### Fixed + +- **cosim on SRAM-less designs** no longer panics. A design with zero SRAM + produced a nil `MTLBuffer` (`new_buffer(0)`) whose `.contents()` is null; + the SRAM data buffer is now sized to `max(1)` word (matching the X-mask + shadow buffer), so pure-logic designs run under `jacquard cosim`. + ## [0.1.0] - 2026-06-04 First numbered release. Metal (macOS/Apple Silicon) is the shipped diff --git a/src/sim/cosim_metal.rs b/src/sim/cosim_metal.rs index f0b19bbb..748ee97f 100644 --- a/src/sim/cosim_metal.rs +++ b/src/sim/cosim_metal.rs @@ -1065,7 +1065,6 @@ fn set_bit(state: &mut [u32], pos: u32, val: u8) { } /// Clear a single bit in a packed u32 state buffer. -#[allow(dead_code)] #[inline] fn clear_bit(state: &mut [u32], pos: u32) { state[(pos >> 5) as usize] &= !(1u32 << (pos & 31)); @@ -2589,9 +2588,14 @@ pub fn run_cosim( ); } - // SRAM storage + // SRAM storage. Allocate at least one word: a SRAM-less design has + // `sram_storage_size == 0`, and `new_buffer(0)` returns a nil MTLBuffer + // whose `.contents()` is null (foreign-types asserts non-null). Sizing to + // `max(1)` while slicing to the real length keeps SRAM-free designs (e.g. + // pure-logic cosim) working; the kernel guards SRAM reads on the cell map. + let sram_data_len = (script.sram_storage_size as usize).max(1); let sram_data_buffer = simulator.device.new_buffer( - (script.sram_storage_size as usize * std::mem::size_of::()) as u64, + (sram_data_len * std::mem::size_of::()) as u64, MTLResourceOptions::StorageModeShared, ); let sram_data: &mut [u32] = unsafe { @@ -2675,6 +2679,62 @@ pub fn run_cosim( } let _ = sram_data; + // Register value-injection (issue #108, ADR 0016). `reg_init` deposits a + // definite value into chosen registers at tick 0 with `$deposit` + // semantics — the seed clears the power-up X-mask, then the design drives + // the register normally. The register sibling of `sram_init` above; the + // fix path for X-poisoned unreset CDC launch registers (#102). NOT + // `force`: applied once here, not re-driven each edge. + // + // A DFF Q is sequential state, not a primary input in the per-edge BitOp + // schedule, so a deposit must survive `state_prep`'s output→input copy + // (kernel_v1.metal): write the value AND clear the X-mask in BOTH slots, + // mirroring the xprop seed above. The output slot is the source of truth + // the first `simulate` reads; the input slot covers any pre-copy read. + if !config.reg_init.is_empty() { + let rio = script.reg_io_state_size as usize; + let mut deposited_bits = 0usize; + for entry in &config.reg_init { + let width = entry.width.unwrap_or(1); + for b in 0..width { + // A scalar register (width 1, however spelled) resolves by its + // bare name; a wider one resolves each bit `name[b]` (a + // register's bits need not be contiguous after synthesis). + let bit_name = if width == 1 { + entry.name.clone() + } else { + format!("{}[{}]", entry.name, b) + }; + let pos = crate::sim::trace_signals::resolve_to_input_state_pos( + aig, netlistdb, script, &bit_name, + ) + .unwrap_or_else(|| { + panic!( + "reg_init: cannot resolve register '{}' to a DFF state slot \ + (not a register, or name not found in netlist)", + bit_name + ) + }); + let val = if b < 64 { ((entry.value >> b) & 1) as u8 } else { 0 }; + // Deposit the value into both slots' value sections. + set_bit(&mut states[..state_size], pos, val); + set_bit(&mut states[state_size..2 * state_size], pos, val); + // Clear the X-mask in both slots so the deposit reads known. + // The X-mask section starts `rio` words into each slot. + if script.xprop_enabled { + clear_bit(&mut states[rio..state_size], pos); + clear_bit(&mut states[state_size + rio..2 * state_size], pos); + } + deposited_bits += 1; + } + } + clilog::info!( + "reg_init: deposited {} register bit(s) from {} config entries (cleared power-up X)", + deposited_bits, + config.reg_init.len() + ); + } + // Initialize: set reset active let reset_val = if config.reset_active_high { 1u8 } else { 0u8 }; set_bit( diff --git a/src/sim/trace_signals.rs b/src/sim/trace_signals.rs index ab4b6c5d..46c90711 100644 --- a/src/sim/trace_signals.rs +++ b/src/sim/trace_signals.rs @@ -301,17 +301,25 @@ pub fn register_trace_signals( /// /// Used by the cosim bus-trace param builder to bind each protocol pin /// to a state position the GPU kernel can read. +/// Parse `name` and resolve it to a netlistdb net id, scanning the +/// multi-candidate interpretations (`parse_signal_name`) until one hits +/// `netname2id`. Shared by the state-position resolvers below. Returns `None` +/// if the name can't be parsed or matches no net. +fn resolve_net_id(netlistdb: &NetlistDB, name: &str) -> Option { + let candidates = parse_signal_name(name).ok()?; + candidates.iter().find_map(|c| { + let key = (c.hier.clone(), c.leaf.clone(), c.bit); + netlistdb.netname2id.get(&key).copied() + }) +} + pub fn resolve_to_state_pos( aig: &AIG, netlistdb: &NetlistDB, script: &FlattenedScriptV1, name: &str, ) -> Option { - let candidates = parse_signal_name(name).ok()?; - let netid = candidates.iter().find_map(|c| { - let key = (c.hier.clone(), c.leaf.clone(), c.bit); - netlistdb.netname2id.get(&key).copied() - })?; + let netid = resolve_net_id(netlistdb, name)?; let pinid = netlistdb.net2pin.iter_set(netid).next()?; let iv = aig.pin2aigpin_iv[pinid]; if iv == usize::MAX { @@ -328,6 +336,41 @@ pub fn resolve_to_state_pos( None } +/// Resolve a hierarchical register (DFF) name to its bit position in the +/// **input** state slot — the DFF Q slot a power-up value must be deposited +/// into for `reg_init` value-injection (issue #108). +/// +/// Distinct from [`resolve_to_state_pos`], which reads `output_map` (keyed by +/// DFF *D* inputs) for bus-trace pin binding. A register *name* resolves to +/// the net driven by the DFF's *Q*, whose aigpin lives in `input_map`. +/// Returns `None` if the name doesn't resolve to a DFF Q present in the +/// flattened script (a pure-comb net, a constant, or an unknown name). +pub fn resolve_to_input_state_pos( + aig: &AIG, + netlistdb: &NetlistDB, + script: &FlattenedScriptV1, + name: &str, +) -> Option { + let netid = resolve_net_id(netlistdb, name)?; + // Scan every pin on the net, not just the first: the DFF Q *driver* pin + // is the one keyed in `input_map`, but it need not be the net's first pin + // — load pins (e.g. adder inputs consuming the register) share the net. + // `input_map` is keyed by the **bare** aigpin (`dff.q`, from + // `add_aigpin`), whereas `pin2aigpin_iv` is iv-encoded + // (`aigpin << 1 | invert`); recover the bare aigpin with `>> 1`, which + // also drops the per-pin invert so both polarities collapse to one key. + for pinid in netlistdb.net2pin.iter_set(netid) { + let iv = aig.pin2aigpin_iv[pinid]; + if iv == usize::MAX { + continue; + } + if let Some(&pos) = script.input_map.get(&(iv >> 1)) { + return Some(pos); + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/testbench.rs b/src/testbench.rs index 986b534c..285a2a64 100644 --- a/src/testbench.rs +++ b/src/testbench.rs @@ -211,6 +211,12 @@ pub struct TestbenchConfig { #[serde(default)] pub jtag: Option, pub sram_init: Option, + /// Register value-injection (`$deposit` at tick 0). See [`RegInitEntry`] + /// and issue #108. The register sibling of `sram_init`: clears power-up X + /// on chosen registers (e.g. unreset CDC launch flops) so X-aware cosim of + /// debug-loaded firmware can proceed (#102). Empty when absent. + #[serde(default)] + pub reg_init: Vec, pub output_events: Option, pub events_reference: Option, /// Path to an `input.json` file containing a list of stimulus @@ -427,6 +433,33 @@ pub struct SramInitConfig { pub elf_path: String, } +/// One register value-injection entry for cosim (`reg_init` in the testbench +/// JSON). Deposits a definite value into a register at tick 0 with `$deposit` +/// semantics — the seed clears the power-up X-mask, then the design's own +/// logic drives the register normally. The register sibling of `sram_init` +/// (issue #108) and the fix path for X-poisoned unreset CDC launch registers +/// (#102). +/// +/// NOT `force`: the value is applied once at t0, not re-driven every edge, so +/// a CDC crossing register carries real protocol data after t0 rather than +/// being pinned to the seed (which would write zeros across the handshake). +#[derive(Debug, Clone, Deserialize)] +pub struct RegInitEntry { + /// Hierarchical register (DFF Q) name. For a scalar register, the bare + /// name; for a multi-bit register, the bus base — each bit `name[b]` for + /// `b` in `0..width` is resolved independently (synthesis need not keep a + /// register's bits at contiguous state positions). + pub name: String, + /// Value deposited LSB-first across the register's bits. Defaults to 0, + /// which alone suffices to clear power-up X (the protocol then drives real + /// values). Bits at index ≥ 64 are treated as 0. + #[serde(default)] + pub value: u64, + /// Register bit width. Omit (or 1) for a scalar resolved by the bare name; + /// `> 1` resolves `name[0]`..`name[width-1]`. + pub width: Option, +} + // ── UART TX decoder ───────────────────────────────────────────────────────── /// UART TX decoder state machine. diff --git a/tests/xprop_cosim/check.py b/tests/xprop_cosim/check.py index 43573f73..6db7dcde 100644 --- a/tests/xprop_cosim/check.py +++ b/tests/xprop_cosim/check.py @@ -30,9 +30,14 @@ that samples the undriven input). sim leaves these known because sim inputs all come from the VCD. two-state a run WITHOUT --xprop: no X/Z on any present signal. + reg-init cosim with --xprop AND a `reg_init` deposit on the unreset + counter (issue #108): q_unreset must now be KNOWN 0/1 for the + whole run — the $deposit cleared the power-up X that mode + `xprop` requires to persist. The inverse assertion of `xprop`, + proving register value-injection works end to end. Usage: - check.py {xprop|xprop-cosim|two-state} + check.py {xprop|xprop-cosim|two-state|reg-init} """ import sys @@ -41,7 +46,7 @@ # Undriven-input outputs, only meaningful in cosim (#95 phase 3). UNDRIVEN = ("q_undriven_comb", "q_undriven_reg") SIGNALS = CORE + UNDRIVEN -MODES = ("xprop", "xprop-cosim", "two-state") +MODES = ("xprop", "xprop-cosim", "two-state", "reg-init") def parse_vcd(path): @@ -120,6 +125,22 @@ def fail(msg): bad = sig[name]["values"] & {"x", "z"} if bad: fail(f"{name} has {sorted(bad)} in a two-state run (no --xprop)") + elif mode == "reg-init": + # q_unreset: the unreset counter was seeded via `reg_init` ($deposit), + # so its power-up X is cleared — it must be a KNOWN 0/1 for the whole + # run (the LSB toggles each cycle from a definite seed). This is the + # exact inverse of the `xprop` mode and proves value-injection works. + bad = sig["q_unreset"]["values"] & {"x", "z"} + if bad: + fail( + f"q_unreset still has {sorted(bad)} after a reg_init deposit " + "(value-injection did not clear the power-up X)" + ) + if sig["q_unreset"]["last"] not in ("0", "1"): + fail(f"q_unreset should end known, last={sig['q_unreset']['last']}") + # q_reset is unaffected by reg_init; it must still resolve. + if sig["q_reset"]["last"] not in ("0", "1"): + fail(f"q_reset should resolve to known 0/1, last={sig['q_reset']['last']}") else: # xprop or xprop-cosim # q_unreset: uninitialised counter, X+1=X, must be X for the whole run. if sig["q_unreset"]["values"] != {"x"}: diff --git a/tests/xprop_cosim/sim_config_reginit.json b/tests/xprop_cosim/sim_config_reginit.json new file mode 100644 index 00000000..9112a110 --- /dev/null +++ b/tests/xprop_cosim/sim_config_reginit.json @@ -0,0 +1,23 @@ +{ + "_comment": "reg_init value-injection demo (#108). Same design/ports as sim_config.json, plus a reg_init that deposits a definite value into the otherwise-unreset `unreset_count` counter at tick 0 ($deposit semantics). Under --xprop this clears the power-up X, so q_unreset (= unreset_count[0]) now reads KNOWN 0/1 for the whole run instead of X. See tests/xprop_cosim/check.py mode reg-init.", + "netlist_path": "tests/xprop_cosim/xprop_demo_synth.gv", + "clock_gpio": 0, + "reset_gpio": 1, + "reset_active_high": false, + "reset_cycles": 10, + "num_cycles": 2000, + "clock_period_ps": 40000, + "port_mapping": { + "inputs": { + "0": "clk", + "1": "rst_n" + }, + "outputs": { + "2": "q_unreset", + "3": "q_reset" + } + }, + "reg_init": [ + { "name": "unreset_count", "value": 5, "width": 4 } + ] +}