Skip to main content

vj_sim/sched/
kernel_adapter.rs

1//! Task S4 (`docs/superpowers/plans/2026-09-26-wave-A2-slice.md`): the ONE
2//! place in this tree a frozen-ABI compiled kernel (`vj_abi::ProcessFn`)
3//! is bridged to `sched::CompiledProcess` -- ledger PR-100, PR-101, PR-102,
4//! PR-103, PR-108, PR-109, PR-110, PR-112, PR-121, CG-083, CG-087, CG-088,
5//! CG-089, CG-106, CG-107, CG-116, CG-164. See `docs/design/process.md` §4
6//! and ARCHITECTURE.md §5/§6.4.
7//!
8//! ## `SV_KERNEL_ABI_RECONCILIATION` (CG-084/CG-111..CG-116 supersession)
9//!
10//! `docs/design/codegen.md` §9's literal `SvKernelTable`/`SvProcessDesc`/
11//! `SvWakeSpec`/`SvResumeStatus` draft is superseded by ARCHITECTURE.md §5's
12//! simpler, frozen ABI (`SimHandle`/`ProcessFn`/`EvalResult`/`Checkpoint`) --
13//! the same reconciliation `docs/ledger.json`'s own `PR-037`/`PR-107` rows
14//! already record for the process-model side. This module closes CG-084 and
15//! CG-111 through CG-116 by implementing the frozen ABI shape below, not the
16//! literal old struct names: [`CompiledKernelProcess`] IS what
17//! `SvKernelTable`'s "resume a compiled process" ask reduces to once
18//! `resume_pc` is a plain function argument instead of a state-arena-resident
19//! discriminant slot (CG-088/CG-089's own reconciliation, applied at the
20//! `vj-codegen::process_lower::lower_kernel_fn` call site that emits the
21//! LLVM side of this same boundary).
22//!
23//! ## PR-121: `RescheduleReq`'s opaque bytes, given real meaning
24//!
25//! [`build_eval_result`]/[`decode_reschedule`] are the only two functions in
26//! this tree that ever read or write `vj_abi::eval::RescheduleReq::opaque`
27//! -- everywhere else treats it as an inert 32-byte blob. They reuse (never
28//! reinvent) `super::reschedule_req_proposal`'s already-tested 24-byte tagged
29//! union, packed into the frozen 32-byte `opaque` array alongside 4 more bytes
30//! (`next_resume_pc`, little-endian) and 4 reserved zero bytes -- the "8 of
31//! the 32 reserved bytes free for a future field" headroom that module's own
32//! doc comment already sized in for.
33//!
34//! ## Known A2-slice scope limit -- CLOSED (A2-part2 review-fix r1)
35//!
36//! The frozen `ProcessFn = extern "C" fn(SimHandle, *mut u8, u32) ->
37//! EvalResult` signature carries no `ProcessId`/`ProcessInstanceId` parameter
38//! at all -- there is nowhere in this ABI boundary for a compiled kernel to
39//! report "which process instance is this" to `nba_enqueue`'s own `source:
40//! u32` parameter, so every `nba_enqueue` call this wave's codegen emits
41//! still passes a constant `0` for `source` (`vj-codegen::process_lower`'s
42//! own doc comment on its NBA lowering repeats this same note at the
43//! call-emission site) -- an ABI-breaking 4th `ProcessFn` parameter remains
44//! out of this task's authority (AB-050 is a cross-track-negotiated
45//! contract). What this review fix closes is the OTHER half this section
46//! used to flag as unbuilt: the runtime-side lookup keyed off "current
47//! process" ambient state. [`CURRENT_PROCESS`]/[`with_current_process`]
48//! below are exactly that -- `sched::resume::ResumeDriver::resume_for`/
49//! `start` (the two places SCHED already knows the real `ProcessId` it is
50//! about to resume) install it for the duration of the `resume()` call, and
51//! [`real_nba_enqueue`] prefers it over the always-`0` raw ABI value. A test
52//! exercising two concurrently-nonblocking-writing process instances in one
53//! time step now exists (`vj-harness`'s
54//! `two_process_nba_same_var_matches_iverilog_bit_for_bit`, against a real
55//! Icarus run) -- see that fixture's own doc comment for why it uses a
56//! causal (not same-event-race) dependency between the two processes: the
57//! LRM leaves same-event multi-process activation order unspecified, and
58//! `commit_nba`'s own hazard resolution (`sched::notify::commit_nba`)
59//! already, deliberately, ties break by push/insertion order rather than by
60//! `ProcessId` (see that function's own doc comment) -- this fix's real
61//! contribution is that `source` is no longer silently dishonest about which
62//! process staged a write, not a change to that already-correct resolution
63//! algorithm.
64//!
65//! **r2 addendum (review fix, A2-part2 r2, finding 3):** the harness test
66//! named above proves `commit_nba`'s push-order resolution, not that
67//! `CURRENT_PROCESS` itself is wired correctly -- `commit_nba` never reads
68//! `source` at all, so that differential test cannot distinguish "wired
69//! correctly" from "absent/always-0". [`tests::
70//! real_nba_enqueue_prefers_current_process_over_the_raw_abi_source`] closes
71//! that gap directly: it calls [`real_nba_enqueue`] with and without
72//! [`with_current_process`] installed and asserts the recorded `source`
73//! differs accordingly, independent of `commit_nba`/push order entirely.
74//!
75//! ## Frame-storage deviation from the plan's literal 3-field struct
76//!
77//! The plan's own struct sketch is `{ kernel, activation: Box<ActivationRecord>,
78//! resume_pc: u32 }`. `ActivationRecord::frame` is (correctly -- see that
79//! field's own doc comment) only a non-owning [`crate::process::ArenaRange`]
80//! *descriptor* into a separate, STATE-owned per-process frame arena; no such
81//! frame-arena allocator exists anywhere in this tree yet (`state::instance::
82//! StateEngine::allocate_instance` allocates the *signal* arena, a different
83//! one, and no other committed code in this wave or an earlier one builds a
84//! frame-word allocator). Since the frozen ABI's `local: *mut u8` parameter
85//! must point at real, live, correctly-sized byte storage for `resume()` to
86//! be callable at all, [`CompiledKernelProcess`] owns that byte buffer
87//! directly (the `frame` field below) until a real frame-arena allocator
88//! lands in a later wave and this adapter is updated to resolve
89//! `activation.frame` against it instead.
90use std::cell::{Cell, RefCell};
91use std::ffi::CStr;
92
93use vj_abi::eval::{EvalOutcome, EvalResult, RescheduleReq};
94use vj_abi::{ProcessFn, SimHandle};
95
96use super::reschedule_req_proposal::{
97    KIND_BY_REQUEST, KIND_DELAY, KIND_DONE, KIND_EDGE, KIND_EDGE_LIST, KIND_FORK_JOIN,
98    KIND_REPEAT_EDGE, KIND_WAIT_FORK, RescheduleReqLayoutProposal,
99};
100use super::{
101    CompiledProcess, EventTerm, EventWaitSet, JoinKind, ProcessId, ProcessRequest, ProcessState,
102    ResumeArg, SchedulerOps, SpawnSpec, Suspend,
103};
104use crate::dpi_task::{CallPoll, ExportRequest, TaskCall};
105use crate::process::{ActivationRecord, ArenaRange, PredicateFn, WaitSpecStorage};
106use crate::state::{EdgeKind, FourStateValue, InstanceHandle, StateCellId};
107
108/// Byte offset, within [`RescheduleReq::opaque`], where this module stashes
109/// the raw `next resume_pc` a caller must pass on its following call --
110/// immediately after the 24-byte [`RescheduleReqLayoutProposal`] (PR-121's
111/// "8 of the 32 reserved bytes free for a future field" headroom).
112const NEXT_RESUME_PC_OFFSET: usize = 24;
113/// Byte offset of the flags word in the `opaque` bytes' reserved tail.
114const FLAGS_OFFSET: usize = 28;
115/// Flag bit: the wait is a `wait(expr)` -- see [`EventWaitSet::rechecks_on_wake`].
116const FLAG_RECHECKS_ON_WAKE: u8 = 1;
117const PROPOSAL_SIZE: usize = 24;
118
119/// The compile-time-resolved suspend shape [`build_eval_result`] encodes.
120/// Deliberately **not** `vj_sim::process::cfg::SuspendPoint` (an
121/// `sv_lang`-carrying, `frontend`-gated type): this module -- and the whole
122/// `sched` module tree -- must build with `--no-default-features` (D4), so
123/// every type crossing this boundary is `sv_lang`-free. `vj-codegen`
124/// resolves its own `SuspendPoint` (a constant delay tick count, or an edge
125/// signal's already-assigned `SlotId`) down to this shape before calling in.
126#[derive(Clone, Copy, Debug)]
127pub enum ResolvedSuspend {
128    Delay(u64),
129    /// A delay computed at run time (`#d`, `#(expr)`): the kernel stores
130    /// the tick count with [`real_sv_rt_set_delay`] just before it returns.
131    DynamicDelay,
132    Edge {
133        cell: StateCellId,
134        edge: EdgeKind,
135    },
136    Done,
137    /// **Task 6 (CG-081/CG-171):** a single-cell, single-edge REPEAT wait --
138    /// `count` occurrences of `edge` on `cell` must fire before the process
139    /// resumes. Closes `SuspendPoint::CycleDelay` (`##N`, always reachable
140    /// via a `Timed` statement -- FULLY resolved by this task) and would
141    /// close `RepeatedEventControl` (`repeat(n) @(...)`, CG-081's own name
142    /// for this kind) too, were that AST shape ever reachable from a `Timed`
143    /// statement at all -- see `vj-codegen::process_lower::
144    /// resolve_suspend_point`'s `CycleDelay` arm and `vj-sim::process::
145    /// cfg::SuspendPoint`'s own doc comment for the full reasoning on both.
146    /// `count` is the RAW, un-multiplied cycle count -- `edge`s of `cell`
147    /// are counted LIVE at scheduler runtime (`EventTerm::Repeat`,
148    /// SC-052/SC-053, already built and tested), never pre-multiplied
149    /// against any period at codegen time (ARCHITECTURE §6.5).
150    RepeatEdge {
151        count: u64,
152        cell: StateCellId,
153        edge: EdgeKind,
154    },
155    /// **Task 7 (AB-096/CG-082):** a single-cell, single-edge wait carrying a
156    /// compiled re-check guard -- the `wait(expr)`/`iff`-qualified shape.
157    /// See [`RescheduleReqLayoutProposal::from_single_edge_with_guard`]'s
158    /// own doc comment for the full ABI-budget reasoning behind reusing
159    /// `a`/`b` instead of a ninth struct field, and for why this is NOT
160    /// extended to `RepeatEdge` in this pass.
161    EdgeWithGuard {
162        cell: StateCellId,
163        edge: EdgeKind,
164        guard_fn: extern "C" fn(SimHandle) -> bool,
165    },
166    /// **R-run Task 17 (PR-047/PR-061/PR-085):** a `fork ... join`
167    /// (`JoinKind::All` only -- this task's own scope) suspend. Unlike
168    /// every other variant above, a fork/join site's real payload (one
169    /// [`super::SpawnSpec`] per branch) is NOT self-contained here -- it is
170    /// compile-time-known (branches are static source, not a per-instance
171    /// runtime count) but too large to fit `RescheduleReqLayoutProposal`'s
172    /// frozen 24-byte budget for more than ~2 branches, so `vj-codegen`
173    /// instead materializes a real, compile-time-constant
174    /// `Vec<SpawnSpec>` fork table (one per compiled program, growing as
175    /// each fork/join site is lowered) and this variant carries only
176    /// `base`/`count`, an [`crate::process::ArenaRange`]-shaped reference
177    /// into it -- exactly [`RescheduleReqLayoutProposal::from_fork_join`]'s
178    /// own, already-specified `KIND_FORK_JOIN` wire shape (`docs/CONTRACTS.md`
179    /// open question 8 / D1a's own "no allocator, no STATE-owned storage"
180    /// concern does not apply here: this table is compiled INTO the
181    /// program as an ordinary Rust `Vec` a `CompiledKernelProcess` is handed
182    /// at construction time -- see [`decode_reschedule_with_fork_table`]).
183    ForkJoin {
184        join_kind: JoinKind,
185        base: u32,
186        count: u32,
187    },
188    /// `wait fork;` (SC-125) -- see `reschedule_req_proposal::KIND_WAIT_FORK`.
189    WaitFork,
190    /// `p.await()` / `p.suspend()` (classes plan Task 13) -- see
191    /// `reschedule_req_proposal::KIND_BY_REQUEST`.
192    ByRequest,
193    /// A wait on several terms (`@*`, `@(a or posedge b)`, `wait(expr)` on a
194    /// wide or multi-signal condition, `@(a)` any-change): the terms live in
195    /// the program's wait table (`[base, base+count)`, see [`WaitTableEntry`]),
196    /// encoded as `KIND_EDGE_LIST`.
197    EventList {
198        base: u32,
199        count: u32,
200    },
201}
202
203/// One term of a multi-term wait, in the program's wait table: the waited-on
204/// signal's TEMPLATE slot (mapped to the waiting instance's cell at run time)
205/// and its edge -- `None` = any value change of any bit.
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub struct WaitTableEntry {
208    pub slot: u32,
209    pub edge: Option<EdgeKind>,
210    /// `Some((lsb, width))`: the term watches only bits `[lsb, lsb + width)`
211    /// of the signal -- a constant bit/part select (`@(posedge v[1])`, LRM
212    /// 9.4.2) -- and an edge is taken on the select's least significant bit.
213    pub select: Option<(u32, u32)>,
214}
215
216impl WaitTableEntry {
217    /// A term on the whole signal.
218    pub fn whole(slot: u32, edge: Option<EdgeKind>) -> Self {
219        Self {
220            slot,
221            edge,
222            select: None,
223        }
224    }
225}
226
227/// PR-121: builds a real `vj_abi::eval::EvalResult` from a
228/// [`ResolvedSuspend`] and the raw `next_resume_pc` a caller must pass on its
229/// next call for this same process (a plain `u32`, `vj-codegen`'s
230/// `lower_kernel_fn` supplies the already-known target `SegId` here at
231/// codegen time; see [`decode_reschedule`] for the inverse this module gives
232/// the ABI's opaque bytes real meaning through, layered on top of
233/// `RescheduleReqLayoutProposal`'s already-tested 24-byte tagged union rather
234/// than inventing a second encoding).
235pub fn build_eval_result(resolved: ResolvedSuspend, next_resume_pc: u32) -> EvalResult {
236    let (outcome, proposal) = match resolved {
237        ResolvedSuspend::Delay(ticks) => (
238            EvalOutcome::SuspendedOnDelay,
239            RescheduleReqLayoutProposal::from_delay(ticks),
240        ),
241        ResolvedSuspend::DynamicDelay => (
242            EvalOutcome::SuspendedOnDelay,
243            RescheduleReqLayoutProposal::from_delay(DYNAMIC_DELAY_MARK),
244        ),
245        ResolvedSuspend::Edge { cell, edge } => (
246            EvalOutcome::SuspendedOnEvent,
247            RescheduleReqLayoutProposal::from_single_edge(edge, cell),
248        ),
249        ResolvedSuspend::Done => (EvalOutcome::Completed, RescheduleReqLayoutProposal::done()),
250        ResolvedSuspend::RepeatEdge { count, cell, edge } => (
251            EvalOutcome::SuspendedOnEvent,
252            RescheduleReqLayoutProposal::from_repeat_edge(count, edge, cell),
253        ),
254        // Task 7 (AB-096/CG-082).
255        ResolvedSuspend::EdgeWithGuard {
256            cell,
257            edge,
258            guard_fn,
259        } => (
260            EvalOutcome::SuspendedOnEvent,
261            RescheduleReqLayoutProposal::from_single_edge_with_guard(edge, cell, guard_fn),
262        ),
263        // R-run Task 17: `EvalOutcome` is the frozen `vj-abi` 5-variant
264        // enum (Completed/SuspendedOnEvent/SuspendedOnDelay/
265        // MoreWorkInRegion/RuntimeError) -- no dedicated fork/join
266        // discriminant exists in it (out of this task's authority to add
267        // one, ARCHITECTURE §7 item 3), so this reuses `SuspendedOnEvent`,
268        // the same "process suspended waiting on something" outcome
269        // `KIND_EDGE`/`KIND_EDGE_LIST` above already use; `CompiledKernelProcess
270        // ::resume` never reads `outcome` at all (only `decode_reschedule`'s
271        // decode of `reschedule.opaque` matters for real dispatch), so this
272        // choice is a documentation nicety, not load-bearing.
273        ResolvedSuspend::ForkJoin {
274            join_kind,
275            base,
276            count,
277        } => (
278            EvalOutcome::SuspendedOnEvent,
279            RescheduleReqLayoutProposal::from_fork_join(join_kind, ArenaRange { base, count }),
280        ),
281        ResolvedSuspend::WaitFork => (
282            EvalOutcome::SuspendedOnEvent,
283            RescheduleReqLayoutProposal::wait_fork(),
284        ),
285        ResolvedSuspend::ByRequest => (
286            EvalOutcome::SuspendedOnEvent,
287            RescheduleReqLayoutProposal::by_request(),
288        ),
289        ResolvedSuspend::EventList { base, count } => (
290            EvalOutcome::SuspendedOnEvent,
291            RescheduleReqLayoutProposal::from_edge_list(ArenaRange { base, count }),
292        ),
293    };
294
295    let mut opaque = [0u8; vj_abi::eval::RESCHEDULE_REQ_SIZE];
296    // Task 11 (A2-part2; found, not assigned): this used to be an `unsafe {
297    // core::mem::transmute(proposal) }` of the `#[repr(C)]`
298    // `RescheduleReqLayoutProposal` struct directly to `[u8; PROPOSAL_SIZE]`
299    // -- which reads that struct's own compiler-inserted alignment padding
300    // (between `b: u32` and `edge_signal: u64`) as real bytes, and a plain
301    // struct literal never pins padding to a known value, only its named
302    // fields. `RescheduleReqLayoutProposal::to_bytes` (its own doc comment
303    // has the full story, including why a `mem::zeroed()`-based struct
304    // literal fix was tried first and does not work) writes all 24 bytes
305    // itself instead, explicit `0` for the padding's own offsets -- no
306    // `unsafe`, no transmute, no indeterminate byte survives into this
307    // `EvalResult`, which `vj-codegen::process_lower` freezes directly
308    // into a compile-time LLVM IR constant.
309    let proposal_bytes: [u8; PROPOSAL_SIZE] = proposal.to_bytes();
310    opaque[..PROPOSAL_SIZE].copy_from_slice(&proposal_bytes);
311    opaque[NEXT_RESUME_PC_OFFSET..NEXT_RESUME_PC_OFFSET + 4]
312        .copy_from_slice(&next_resume_pc.to_le_bytes());
313    // opaque[28..32] is the flags word (see `mark_rechecks_on_wake`): explicit
314    // zero here, never left as poison.
315
316    EvalResult {
317        outcome,
318        reschedule: RescheduleReq { opaque },
319    }
320}
321
322/// Marks `er` (a `wait(expr)` suspend) as one whose resumed kernel re-checks
323/// its condition, so the scheduler may replay a wake parked while the process
324/// was suspended ([`EventWaitSet::rechecks_on_wake`]). Only the codegen of a
325/// level-sensitive wait calls this.
326pub fn mark_rechecks_on_wake(mut er: EvalResult) -> EvalResult {
327    er.reschedule.opaque[FLAGS_OFFSET] |= FLAG_RECHECKS_ON_WAKE;
328    er
329}
330
331/// Whether [`mark_rechecks_on_wake`] was applied to `er`.
332fn rechecks_on_wake(er: &EvalResult) -> bool {
333    er.reschedule.opaque[FLAGS_OFFSET] & FLAG_RECHECKS_ON_WAKE != 0
334}
335
336/// Review fix (A2-part2 r2, binding rule 8): the typed failure
337/// [`decode_reschedule`] returns instead of panicking for `KIND_EDGE_LIST`/
338/// `KIND_FORK_JOIN`/any out-of-range kind byte.
339///
340/// **Why this stops at a typed error rather than a full decode into
341/// `Suspend::WaitEvents`'s multi-term shape / `Suspend::ForkJoin`, for these
342/// two kinds specifically:** `RescheduleReqLayoutProposal::from_edge_list`/
343/// `from_fork_join`'s own doc comments are explicit that their payload is
344/// only an [`ArenaRange`] *reference* into, respectively, "a static,
345/// elaboration-time-built edge-term table" and "the engine's dynamic
346/// fork-child table" -- never the concrete `StateCellId`s/`SpawnSpec`s
347/// themselves. Per `docs/CONTRACTS.md` open question 8 and resolved decision
348/// D1a, **neither table is materialized by any wave-A track**: no allocator
349/// and no STATE-owned storage exists yet to resolve `base`/`count` against.
350/// Fabricating a `Suspend::WaitEvents`/`Suspend::ForkJoin` from just a range
351/// with no real backing data would be a silently-wrong `Suspend` (exactly
352/// what this decoder's own prior doc comment already refused to do) --
353/// strictly worse than an honest, typed "cannot resolve this yet" error.
354/// This variant carries the range/`join_kind` bytes through unchanged, so
355/// the future task that finally builds those tables has everything it needs
356/// without inventing a second wire format.
357#[derive(Clone, Copy, PartialEq, Eq, Debug)]
358pub enum DecodeRescheduleError {
359    /// `kind == KIND_EDGE_LIST`: a multi-term `Suspend::WaitEvents` whose
360    /// term data lives in the (not-yet-materialized) edge-term table this
361    /// `ArenaRange` would index into.
362    UnresolvedEdgeList { base: u32, count: u32 },
363    /// `kind == KIND_FORK_JOIN`: a `Suspend::ForkJoin` whose `SpawnSpec`s
364    /// live in the (not-yet-materialized) dynamic fork-child table this
365    /// `ArenaRange` would index into. `join_kind` is the raw
366    /// `JoinKind`-discriminant byte (`RescheduleReqLayoutProposal::
367    /// fork_join_kind`), carried through undecoded since decoding it further
368    /// buys nothing without the range it pairs with also being resolvable.
369    UnresolvedForkJoin {
370        join_kind: u8,
371        base: u32,
372        count: u32,
373    },
374    /// Any byte outside `KIND_DELAY..=KIND_DONE` -- an ABI/version mismatch;
375    /// `build_eval_result` (the only producer in this tree) never emits one.
376    InvalidKind(u8),
377}
378
379/// The inverse of [`build_eval_result`]'s suspend-shape half: decodes
380/// `r.reschedule`'s opaque bytes back into the scheduler-facing [`Suspend`].
381/// Every kind byte [`RescheduleReqLayoutProposal`] can hold is named and
382/// handled -- `KIND_EDGE_LIST`/`KIND_FORK_JOIN` and any out-of-range byte
383/// return a typed [`DecodeRescheduleError`] (never a panic: review fix,
384/// A2-part2 r2, binding rule 8) instead of a silently-wrong `Suspend`. See
385/// [`DecodeRescheduleError`]'s own doc comment for why those two kinds stop
386/// at a typed error rather than a full decode.
387pub fn decode_reschedule(r: EvalResult) -> Result<Suspend, DecodeRescheduleError> {
388    let mut proposal_bytes = [0u8; PROPOSAL_SIZE];
389    proposal_bytes.copy_from_slice(&r.reschedule.opaque[..PROPOSAL_SIZE]);
390    // SAFETY: inverse of `build_eval_result`'s transmute above -- every
391    // `EvalResult` this function is ever called with in this tree was itself
392    // produced by that function, so `proposal_bytes` is a valid
393    // `RescheduleReqLayoutProposal` bit pattern (a plain, `#[repr(C)]`,
394    // all-fields-`Copy` struct: no bit pattern of the right size is invalid).
395    let proposal: RescheduleReqLayoutProposal = unsafe { core::mem::transmute(proposal_bytes) };
396    match proposal.kind {
397        KIND_DELAY => {
398            let ticks = u64::from(proposal.a) | (u64::from(proposal.b) << 32);
399            if ticks == DYNAMIC_DELAY_MARK {
400                return Ok(Suspend::Delay(DYNAMIC_DELAY.with(Cell::get)));
401            }
402            Ok(Suspend::Delay(ticks))
403        }
404        // Task 7 (AB-096): `proposal.guard_fn()` is `Some` iff this
405        // `KIND_EDGE` payload was built via `from_single_edge_with_guard`
406        // (a real `wait(expr)`/`iff`-qualified edge) -- see
407        // `sc068_decode_reschedule_can_never_produce_a_some_iff_edge_term`'s
408        // own doc comment for the "documented flip condition" this arm now
409        // satisfies for that specific shape, while a plain `ResolvedSuspend
410        // ::Edge` (a == b == 0) still decodes `iff: None` exactly as before.
411        KIND_EDGE => Ok(Suspend::WaitEvents(EventWaitSet {
412            terms: vec![EventTerm::Edge {
413                cell: StateCellId(proposal.edge_signal),
414                edge: decode_edge_kind_u8(proposal.edge_kind),
415                iff: proposal.guard_fn().map(PredicateFn),
416            }],
417            rechecks_on_wake: rechecks_on_wake(&r),
418        })),
419        KIND_DONE => Ok(Suspend::Done),
420        KIND_WAIT_FORK => Ok(Suspend::WaitFork),
421        KIND_BY_REQUEST => Ok(Suspend::ByRequest),
422        // Task 6 (CG-081/CG-171): a single-cell, single-edge REPEAT wait --
423        // decodes directly into the ALREADY-tested `EventTerm::Repeat`-over-
424        // `Edge` machinery (SC-052/SC-053), unlike `KIND_EDGE_LIST`/
425        // `KIND_FORK_JOIN` below: this kind's whole payload (`edge_kind`/
426        // `edge_signal`/`a`/`b`) is self-contained inline, no out-of-line
427        // table to resolve.
428        KIND_REPEAT_EDGE => Ok(Suspend::WaitEvents(EventWaitSet {
429            terms: vec![EventTerm::Repeat {
430                count: u64::from(proposal.a) | (u64::from(proposal.b) << 32),
431                inner: Box::new(EventTerm::Edge {
432                    cell: StateCellId(proposal.edge_signal),
433                    edge: decode_edge_kind_u8(proposal.edge_kind),
434                    iff: None,
435                }),
436            }],
437            ..Default::default()
438        })),
439        KIND_EDGE_LIST => Err(DecodeRescheduleError::UnresolvedEdgeList {
440            base: proposal.a,
441            count: proposal.b,
442        }),
443        KIND_FORK_JOIN => Err(DecodeRescheduleError::UnresolvedForkJoin {
444            join_kind: proposal.fork_join_kind,
445            base: proposal.a,
446            count: proposal.b,
447        }),
448        other => Err(DecodeRescheduleError::InvalidKind(other)),
449    }
450}
451
452/// R-run Task 17 (PR-047/PR-061/PR-085): the real, resolvable sibling of
453/// [`decode_reschedule`] for a caller that DOES have a materialized fork
454/// table to resolve `KIND_FORK_JOIN`'s `(base, count)` `ArenaRange` against
455/// -- see [`ResolvedSuspend::ForkJoin`]'s own doc comment for why that table
456/// is a plain, compile-time-constant `Vec<SpawnSpec>` (never a runtime
457/// allocator), and [`CompiledKernelProcess`]'s own `fork_table` field for
458/// where a real caller gets one. Every OTHER kind byte delegates verbatim to
459/// [`decode_reschedule`] (never a second, independently-maintained copy of
460/// its decode logic) -- this function only ever adds behavior for the one
461/// kind that function still cannot resolve on its own.
462///
463/// `fork_table[base..base+count]` out of bounds (an internal-invariant
464/// violation -- `vj-codegen` never constructs a `(base, count)` pair
465/// that does not index its own, matching fork table) re-returns the
466/// original typed [`DecodeRescheduleError::UnresolvedForkJoin`] rather than
467/// panicking, so a caller that mismatches a kernel with the wrong fork
468/// table gets a loud, typed failure, never an out-of-bounds panic.
469pub fn decode_reschedule_with_fork_table(
470    r: EvalResult,
471    fork_table: &[SpawnSpec],
472) -> Result<Suspend, DecodeRescheduleError> {
473    decode_reschedule_with_tables(r, fork_table, &[])
474}
475
476/// [`decode_reschedule_with_fork_table`] plus the program's wait table, so a
477/// `KIND_EDGE_LIST` (multi-term wait, [`ResolvedSuspend::EventList`]) also
478/// resolves: each entry becomes an `Edge` term (edged) or an `ImplicitAny`
479/// term (any change), cells still TEMPLATE slots (the kernel process maps
480/// them to its instance).
481pub fn decode_reschedule_with_tables(
482    r: EvalResult,
483    fork_table: &[SpawnSpec],
484    wait_table: &[WaitTableEntry],
485) -> Result<Suspend, DecodeRescheduleError> {
486    match decode_reschedule(r) {
487        Err(DecodeRescheduleError::UnresolvedEdgeList { base, count }) => {
488            let start = base as usize;
489            let end = start.saturating_add(count as usize);
490            let entries = wait_table
491                .get(start..end)
492                .ok_or(DecodeRescheduleError::UnresolvedEdgeList { base, count })?;
493            Ok(Suspend::WaitEvents(EventWaitSet {
494                terms: entries
495                    .iter()
496                    .map(|e| match (e.select, e.edge) {
497                        (Some((lsb, width)), edge) => EventTerm::Select {
498                            cell: StateCellId(u64::from(e.slot)),
499                            edge: edge.unwrap_or(EdgeKind::None),
500                            lsb,
501                            width,
502                        },
503                        (None, Some(edge)) => EventTerm::Edge {
504                            cell: StateCellId(u64::from(e.slot)),
505                            edge,
506                            iff: None,
507                        },
508                        (None, None) => {
509                            EventTerm::ImplicitAny(vec![StateCellId(u64::from(e.slot))])
510                        }
511                    })
512                    .collect(),
513                rechecks_on_wake: rechecks_on_wake(&r),
514            }))
515        }
516        other => decode_fork_join(other, fork_table),
517    }
518}
519
520fn decode_fork_join(
521    decoded: Result<Suspend, DecodeRescheduleError>,
522    fork_table: &[SpawnSpec],
523) -> Result<Suspend, DecodeRescheduleError> {
524    match decoded {
525        Err(DecodeRescheduleError::UnresolvedForkJoin {
526            join_kind,
527            base,
528            count,
529        }) => {
530            let start = base as usize;
531            let end = start.saturating_add(count as usize);
532            let spawns =
533                fork_table
534                    .get(start..end)
535                    .ok_or(DecodeRescheduleError::UnresolvedForkJoin {
536                        join_kind,
537                        base,
538                        count,
539                    })?;
540            Ok(Suspend::ForkJoin(
541                decode_join_kind_u8(join_kind),
542                spawns.to_vec(),
543            ))
544        }
545        other => other,
546    }
547}
548
549/// The inverse of `JoinKind as u8` (used by
550/// [`RescheduleReqLayoutProposal::from_fork_join`]) -- an out-of-range byte
551/// (an ABI/version mismatch this tree never actually produces, since
552/// `JoinKind` has exactly 3 fieldless variants) falls back to `JoinKind::
553/// None`, mirroring [`decode_edge_kind_u8`]'s own identical "out-of-range
554/// falls back to the safest/most conservative variant" convention just
555/// below, rather than panicking on a byte this function cannot itself prove
556/// is unreachable.
557fn decode_join_kind_u8(b: u8) -> JoinKind {
558    if b == JoinKind::All as u8 {
559        JoinKind::All
560    } else if b == JoinKind::Any as u8 {
561        JoinKind::Any
562    } else {
563        JoinKind::None
564    }
565}
566
567fn decode_edge_kind_u8(b: u8) -> EdgeKind {
568    if b == EdgeKind::Posedge as u8 {
569        EdgeKind::Posedge
570    } else if b == EdgeKind::Negedge as u8 {
571        EdgeKind::Negedge
572    } else if b == EdgeKind::BothEdges as u8 {
573        EdgeKind::BothEdges
574    } else {
575        EdgeKind::None
576    }
577}
578
579/// PR-100/PR-101/PR-102/PR-108/PR-109/PR-110: the ONE adapter bridging a
580/// JIT'd/AOT'd `vj_abi::ProcessFn` into `sched::CompiledProcess` -- see
581/// this module's own doc comment for why it also owns its instance's frame
582/// bytes directly (a documented deviation from the plan's literal 3-field
583/// sketch) and for the `source: 0` NBA scope limit.
584///
585/// **Task S7 finding (the `SimHandle` this struct's own `resume()` calls the
586/// kernel with is `self.sim_handle`, NEVER `resume()`'s own `state`
587/// parameter):** verified against the real `Engine` (`sched::control.rs`),
588/// which passes `SimHandle(std::ptr::null_mut())` to every `CompiledProcess::
589/// resume()` call unconditionally (`sched` is deliberately STATE-agnostic --
590/// it dispatches by `ProcessId` against a generic `Box<dyn SignalAccess>`,
591/// never a per-instance `SimHandle`) -- before this task, no committed code
592/// anywhere in this tree ever drove `CompiledKernelProcess` through a real
593/// `Engine`, so this null-handle-always behavior, while real and by design on
594/// SCHED's side, had never actually reached a compiled kernel's own
595/// `sv_get_scalar`/`sv_set_scalar` calls in practice (every existing test
596/// calls `.resume()` directly with a handle it already controls). Since each
597/// `CompiledKernelProcess` is permanently bound to exactly one process
598/// INSTANCE (and therefore exactly one STATE `SimHandle`) for its whole
599/// life, the fix is for this adapter to own and use that handle itself,
600/// ignoring whatever `resume()`'s own `state` parameter happens to carry --
601/// consistent with SCHED's own "generic over `SignalAccess`, no `SimHandle`
602/// concept" design, not a workaround of it.
603/// Set on the parent word of a [`ForkKernel::captures`] pair that copies the
604/// other way: child frame word -> parent frame word, when the child is done
605/// (a recursive task's `output`/`inout` formals).
606pub const RETURN_COPY: u32 = 1 << 31;
607
608/// The words a finished fork child hands its parent: (parent word, value).
609type ReturnBox = std::rc::Rc<RefCell<Option<Vec<(u32, u64)>>>>;
610
611pub struct CompiledKernelProcess {
612    pub kernel: ProcessFn,
613    pub activation: Box<ActivationRecord>,
614    pub resume_pc: u32,
615    frame: Box<[u8]>,
616    sim_handle: SimHandle,
617    /// R-run Task 17 (PR-085): the whole compiled program's fork-child
618    /// table (see [`ResolvedSuspend::ForkJoin`]'s own doc comment) --
619    /// empty (`Arc::from([])`, [`Self::new`]'s default) for every kernel
620    /// that never lowers a fork/join site, in which case
621    /// [`decode_reschedule_with_fork_table`] never actually indexes it (no
622    /// `KIND_FORK_JOIN` byte is ever produced for such a kernel). Shared
623    /// (`Arc`, never a per-instance clone of the whole table) because every
624    /// `CompiledKernelProcess` instance minted from kernels of the SAME
625    /// compiled program indexes the SAME table.
626    fork_table: std::sync::Arc<[SpawnSpec]>,
627    /// The whole compiled program's kernels, indexed by [`SpawnSpec::kernel`]
628    /// (`None` for an index that is not a fork-branch kernel, e.g. a
629    /// top-level process). Shared like `fork_table`; empty for a program
630    /// with no fork/join site. [`CompiledProcess::mint_fork_child`] builds
631    /// each branch's child process from it.
632    fork_kernels: std::sync::Arc<[Option<ForkKernel>]>,
633    /// Whether `sim_handle` is a real STATE `InstanceHandle`, so waited-on
634    /// template slots can be mapped to this instance's cells (see `resume`).
635    map_slots_to_instance: bool,
636    /// The program's multi-term wait table ([`WaitTableEntry`]), shared like
637    /// `fork_table`; empty when no kernel emits a multi-term wait.
638    wait_table: std::sync::Arc<[WaitTableEntry]>,
639    /// The instance and `(resume pc, label, exit pc)` table a `disable` of
640    /// another process finds this one by ([`CompiledProcess::disable_exit`]).
641    disable_instance: Option<u32>,
642    disable_scopes: std::sync::Arc<[(u32, String, u32)]>,
643    /// The last suspend was a fork/join wait ([`CompiledProcess::redirect_resume`]).
644    joined: bool,
645    /// The process ran to its end: nothing is left to disable.
646    finished: bool,
647    /// Every kernel resume runs on a fiber, so that C code of an import task
648    /// it calls can block in an export task (see [`Self::with_task_fibers`]).
649    task_fibers: bool,
650    /// The assertion inputs the process's first run reads
651    /// ([`proc_assert::set_action_inputs`]): the sampled arguments of a task
652    /// spawned from a sequence match item (16.11); taken by that run.
653    start_inputs: Option<Vec<i64>>,
654    /// The kernel call parked on its fiber inside an import task (LRM 35.5.3),
655    /// until the export task it asked for has run.
656    call: Option<TaskCall>,
657    /// Where the fiber leaves the `EvalResult` the kernel returned.
658    fiber_result: std::rc::Rc<Cell<Option<EvalResult>>>,
659    /// What the parked [`Self::call`] is waiting for; read by
660    /// [`CompiledProcess::mint_fork_child`] to start the export task.
661    request: Option<ExportRequest>,
662    /// On an export-task child: the argument image of the import task's
663    /// thunk, which receives the child's frame when the child is done so the
664    /// `output`/`inout` formals reach the C caller.
665    image_back: Option<(usize, usize)>,
666    /// On a fork child: its frame words (child word, parent word) the parent
667    /// gets when the child is done, and where it leaves them.
668    returns: Vec<(u32, u32)>,
669    return_box: Option<ReturnBox>,
670    /// The children this process waits for that return words, copied into
671    /// the frame when the process resumes.
672    pending_returns: RefCell<Vec<ReturnBox>>,
673}
674
675/// One fork-branch kernel a [`CompiledKernelProcess`] can mint a child from:
676/// its entry point and its own frame size (see [`CompiledKernelProcess::new`]).
677#[derive(Clone, Debug)]
678pub struct ForkKernel {
679    pub kernel: ProcessFn,
680    pub frame_word_count: u32,
681    /// The variables the branch shares with the process that forks it:
682    /// (parent frame word, child frame word) of each one's cell pointer,
683    /// copied into the child as it is minted.
684    pub captures: std::sync::Arc<[(u32, u32)]>,
685    /// The branch's `(resume pc, label, exit pc)` disable scopes: a `disable`
686    /// of a block nested in the branch from another process (LRM 9.6.2).
687    pub disable_scopes: std::sync::Arc<[(u32, String, u32)]>,
688}
689
690// SAFETY: `sim_handle` is a `SimHandle` minted by `state::instance::
691// InstanceHandle::into_sim_handle` and never dereferenced by this struct
692// itself (only handed, opaquely, to the compiled kernel fn pointer, exactly
693// like `resume()`'s own `state: SimHandle` parameter that every other
694// `CompiledProcess` implementor already receives across whatever thread
695// `Engine` runs on). `vj-sim`'s own `Engine`/`ProcessTable` already
696// require `CompiledProcess: Send` for every registered process, so a
697// `CompiledKernelProcess` that could never cross a thread boundary would be
698// useless to register at all; this mirrors that same existing, accepted
699// obligation (STATE's `InstanceHandle`/`Arena` are themselves designed to be
700// accessed from whichever thread ENGINE's WOCB-barrier discipline currently
701// grants exclusive access to, never two threads concurrently).
702unsafe impl Send for CompiledKernelProcess {}
703
704impl CompiledKernelProcess {
705    /// `frame_word_count` is `vj-sim::process::liveness::FrameLayout`'s
706    /// own total word count for this process TEMPLATE (each word 8 bytes,
707    /// matching every other word-addressed frame convention in this
708    /// workspace) -- `0` for a process with no declared locals at all (the
709    /// fast-path, suspend-free case), in which case `resume()` passes a null
710    /// `local` pointer, matching `vj-codegen::process_lower`'s own
711    /// fast-path lowering, which never dereferences it. `sim_handle` is this
712    /// process's own, permanently-bound `SimHandle` (see this struct's own
713    /// doc comment for why `resume()` uses it, not its `state` parameter) --
714    /// `SimHandle(std::ptr::null_mut())` is a legal value here too, and is
715    /// exactly what every pre-Task-S7 test in this file already passes.
716    pub fn new(kernel: ProcessFn, frame_word_count: u32, sim_handle: SimHandle) -> Self {
717        let byte_len = frame_word_count as usize * 8;
718        Self {
719            kernel,
720            activation: Box::new(ActivationRecord {
721                pc: 0,
722                frame: ArenaRange {
723                    base: 0,
724                    count: frame_word_count,
725                },
726                pending_wait: WaitSpecStorage::None,
727                fork_state: None,
728            }),
729            resume_pc: 0,
730            frame: vec![0u8; byte_len].into_boxed_slice(),
731            sim_handle,
732            fork_table: std::sync::Arc::from(Vec::new()),
733            fork_kernels: std::sync::Arc::from(Vec::new()),
734            map_slots_to_instance: false,
735            wait_table: std::sync::Arc::from(Vec::new()),
736            disable_instance: None,
737            disable_scopes: std::sync::Arc::from(Vec::new()),
738            joined: false,
739            finished: false,
740            task_fibers: false,
741            start_inputs: None,
742            call: None,
743            fiber_result: std::rc::Rc::new(Cell::new(None)),
744            request: None,
745            image_back: None,
746            returns: Vec::new(),
747            return_box: None,
748            pending_returns: RefCell::new(Vec::new()),
749        }
750    }
751
752    /// Runs every resume of the kernel on a fiber (LRM 35.5.3, 35.7): when C
753    /// code of an import task calls an export task which consumes time, the
754    /// fiber parks with the kernel's whole stack, this process becomes the
755    /// parent of the export task's process (`Suspend::ForkJoin`), and the
756    /// kernel continues where it was once that process is done. Only for a
757    /// design with a timed export task; any other pays nothing. The children
758    /// this process forks inherit it.
759    pub fn with_task_fibers(mut self) -> Self {
760        self.task_fibers = true;
761        self
762    }
763
764    /// Makes this process reachable by a `disable <label>;` of the other
765    /// processes of `instance` (LRM 9.6.2): while it is suspended and will
766    /// resume at `pc`, a disable of `label` resumes it at the exit `exit` of
767    /// that block or task activation instead, for each `(pc, label, exit)`.
768    pub fn with_disable_scopes(
769        mut self,
770        instance: u32,
771        scopes: std::sync::Arc<[(u32, String, u32)]>,
772    ) -> Self {
773        self.disable_instance = Some(instance);
774        self.disable_scopes = scopes;
775        self
776    }
777
778    /// The values the process's first run reads as its assertion inputs.
779    pub fn with_start_inputs(mut self, inputs: Vec<i64>) -> Self {
780        self.start_inputs = Some(inputs);
781        self
782    }
783
784    /// Installs the program's multi-term wait table (see [`WaitTableEntry`]).
785    pub fn with_wait_table(mut self, wait_table: std::sync::Arc<[WaitTableEntry]>) -> Self {
786        self.wait_table = wait_table;
787        self
788    }
789
790    /// Declares `sim_handle` a real STATE `InstanceHandle` (what `vj
791    /// run` always passes): the template slots a kernel waits on are then
792    /// mapped to this instance's own cells. Must not be set for a handle
793    /// that points at anything else (e.g. a unit test's mock state).
794    pub fn with_instance_slot_mapping(mut self) -> Self {
795        self.map_slots_to_instance = true;
796        self
797    }
798
799    /// R-run Task 17 (PR-085): a builder-style setter installing this
800    /// instance's own fork-child table -- see [`Self::fork_table`]'s own
801    /// doc comment. A real caller (e.g. a fork-child minter closure built
802    /// from `vj-codegen`'s own emitted table) calls this once, right
803    /// after [`Self::new`], for every kernel drawn from a compiled program
804    /// that contains at least one fork/join site (harmless to call for one
805    /// that does not -- the table is simply never consulted).
806    pub fn with_fork_table(mut self, fork_table: std::sync::Arc<[SpawnSpec]>) -> Self {
807        self.fork_table = fork_table;
808        self
809    }
810
811    /// Installs the program's fork-branch kernels (see [`Self::fork_kernels`]).
812    pub fn with_fork_kernels(mut self, fork_kernels: std::sync::Arc<[Option<ForkKernel>]>) -> Self {
813        self.fork_kernels = fork_kernels;
814        self
815    }
816
817    /// Task 7 (PR-116, XC-044): a read-only view of this instance's own
818    /// permanent frame bytes, exactly as `resume()` hands them to the
819    /// compiled kernel's `local` parameter. Needed so a test can prove a
820    /// declared local that the SV source never reads back (PR-017/PR-018's
821    /// "every declared local gets a permanent slot regardless of use," the
822    /// one claim no SV-visible read/write can observe on its own) still had
823    /// its pre-suspend write survive, byte-for-byte, across a real
824    /// suspend/resume round trip -- `activation`/`resume_pc` are already
825    /// `pub` for the identical reason (direct internal-state inspection by
826    /// a test, never by production scheduler code).
827    pub fn frame_bytes(&self) -> &[u8] {
828        &self.frame
829    }
830}
831
832// PR-101/PR-102's ownership-boundary acceptance: this is the ONLY place a
833// compiled kernel is bridged into a `CompiledProcess` -- see
834// `exactly_one_compiledprocess_impl_over_a_kernel_backed_type` (this
835// module's own test, below) for the structural proof and its documented
836// carve-out for `sched::control::UnmintedForkChild` (a pre-existing,
837// unrelated fork/join placeholder, not a kernel adapter).
838impl CompiledProcess for CompiledKernelProcess {
839    /// A fork branch runs in the parent's own instance (same `SimHandle`) and
840    /// shares the program's fork tables, so a nested `fork` inside a branch
841    /// mints its own children the same way.
842    fn mint_fork_child(&self, spec: &SpawnSpec) -> Option<Box<dyn CompiledProcess>> {
843        let fk = self.fork_kernels.get(spec.kernel as usize)?.clone()?;
844        let mut child = CompiledKernelProcess::new(fk.kernel, fk.frame_word_count, self.sim_handle)
845            .with_fork_table(self.fork_table.clone())
846            .with_fork_kernels(self.fork_kernels.clone())
847            .with_wait_table(self.wait_table.clone());
848        child.map_slots_to_instance = self.map_slots_to_instance;
849        child.task_fibers = self.task_fibers;
850        // A timed export task, asked for by the import task parked in this
851        // process: it runs in the instance of the C code's `svScope`, with the
852        // thunk's argument image as its frame, which gets the frame back when
853        // it is done.
854        if let Some(req) = self.request
855            && let Some(row) = self.fork_table.get(req.export as usize)
856            && row.kernel == spec.kernel
857            && row.entry_pc == spec.entry_pc
858        {
859            if !req.handle.is_null() {
860                child.sim_handle = SimHandle(req.handle);
861            }
862            let n = child.frame.len();
863            // SAFETY: `req.args` points at the image on the parked fiber's
864            // stack, `frame_words` words (the thunk sized it from the same
865            // kernel); it is alive while the request is outstanding.
866            unsafe {
867                std::ptr::copy_nonoverlapping(req.args, child.frame.as_mut_ptr(), n);
868            }
869            child.image_back = Some((req.args as usize, n));
870        }
871        // The branch runs in the parent's instance, so a `disable` of a block
872        // nested in it finds the child like any other process there.
873        child.disable_instance = self.disable_instance;
874        child.disable_scopes = fk.disable_scopes.clone();
875        // The variables it shares with this process: their cell pointers.
876        for &(p, c) in fk.captures.iter() {
877            if p & RETURN_COPY != 0 {
878                child.returns.push((c, p & !RETURN_COPY));
879                continue;
880            }
881            let (p, c) = (p as usize * 8, c as usize * 8);
882            if p + 8 <= self.frame.len() && c + 8 <= child.frame.len() {
883                child.frame[c..c + 8].copy_from_slice(&self.frame[p..p + 8]);
884            }
885        }
886        if !child.returns.is_empty() {
887            let bx: ReturnBox = std::rc::Rc::new(RefCell::new(None));
888            child.return_box = Some(bx.clone());
889            self.pending_returns.borrow_mut().push(bx);
890        }
891        Some(Box::new(child))
892    }
893
894    fn disable_instance(&self) -> Option<u32> {
895        self.disable_instance
896    }
897
898    fn disable_exit(&self, label: &str) -> Option<u32> {
899        if self.finished {
900            return None;
901        }
902        if let Some(call) = &self.call {
903            // LRM 35.9: `disable t;` of the import task this process is
904            // blocked in (its C call is parked on an export task) ends the
905            // call; the process resumes where it is, right after the call
906            // returns ([`Self::redirect_resume`]).
907            return (self.request.is_some() && call.import_name() == label)
908                .then_some(self.resume_pc);
909        }
910        self.disable_scopes
911            .iter()
912            .find(|(pc, l, _)| *pc == self.resume_pc && l == label)
913            .map(|(_, _, exit)| *exit)
914    }
915
916    fn owns_disable_scope(&self, label: &str) -> bool {
917        self.disable_scopes.iter().any(|(_, l, _)| l == label)
918    }
919
920    fn redirect_resume(&mut self, pc: u32) -> bool {
921        if let Some(call) = self.call.as_mut() {
922            // The parked import call sees the disable (C: `svIsDisabledState`,
923            // return 1) at its next poll; the export task it waits for is
924            // killed with the rest of the joined children.
925            call.disable();
926            self.request = None;
927            return std::mem::take(&mut self.joined);
928        }
929        self.resume_pc = pc;
930        self.activation.pc = pc;
931        std::mem::take(&mut self.joined)
932    }
933
934    fn resume(&mut self, _state: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
935        // Resuming from an event control or `wait`, or being disabled, is a
936        // flush point for the process's pending deferred assertion reports
937        // (16.4.2, `sched::deferred`); a delay is not (`#0` stays in the same
938        // time step's Active/Inactive iteration, before any Observed region).
939        if matches!(
940            arg,
941            ResumeArg::EventFired { .. } | ResumeArg::WaitOrderSatisfied | ResumeArg::Disabled
942        ) && let Some(proc) = current_process()
943        {
944            crate::sched::deferred::flush_deferred(proc);
945        }
946        // ... and, when it resumes from an event control or `wait` (not from
947        // a delay), for its pending procedural concurrent assertion instances
948        // (16.14.6.2).
949        if !matches!(arg, ResumeArg::Start | ResumeArg::TimeElapsed)
950            && let Some(proc) = current_process()
951        {
952            crate::sched::proc_assert::flush(proc);
953        }
954        // Only an event control or `wait` resumption (or an `always_comb`
955        // triggered again, which resumes the same way) is a violation report
956        // flush point (12.4.2.1); a delay or a fork join is not.
957        if matches!(
958            arg,
959            ResumeArg::EventFired { .. } | ResumeArg::WaitOrderSatisfied
960        ) && let Some(proc) = current_process()
961        {
962            crate::sched::violation::flush_violation_reports(proc);
963        }
964        if let Some(inputs) = self.start_inputs.take() {
965            crate::sched::proc_assert::set_action_inputs(inputs);
966        }
967        ABORTED.with(|c| c.set(false));
968        let mask = current_process().map_or(0, take_fired);
969        FIRED_MASK.with(|c| c.set(mask));
970        FIRED_TERM.with(|c| {
971            c.set(match arg {
972                ResumeArg::EventFired { term_index } => term_index as i64,
973                _ => -1,
974            })
975        });
976        // The results the children joined since the last run returned.
977        if !self.pending_returns.get_mut().is_empty() {
978            let frame = &mut self.frame;
979            self.pending_returns.get_mut().retain(|bx| {
980                let Some(words) = bx.borrow_mut().take() else {
981                    return true;
982                };
983                for (p, v) in words {
984                    let at = p as usize * 8;
985                    if at + 8 <= frame.len() {
986                        frame[at..at + 8].copy_from_slice(&v.to_le_bytes());
987                    }
988                }
989                false
990            });
991        }
992        let local_ptr = if self.frame.is_empty() {
993            std::ptr::null_mut()
994        } else {
995            self.frame.as_mut_ptr()
996        };
997        // Task S7 finding: `self.sim_handle`, never `resume()`'s own `state`
998        // parameter -- see this struct's own doc comment.
999        //
1000        // Task S7 (the end-to-end pipeline): `ops` is made reachable, for
1001        // exactly the duration of this raw kernel call, to `nba_enqueue`'s/
1002        // `sv_rt_finish`'s real production bodies below via `CURRENT_OPS` --
1003        // see that thread-local's own doc comment for why this is the one
1004        // sound hook point (the frozen `ProcessFn` ABI itself carries no
1005        // scheduler-ops parameter, and `SimHandle` reaches only STATE).
1006        let handle = self.sim_handle;
1007        let result: EvalResult = if self.task_fibers {
1008            if self.call.is_none() {
1009                let (kernel, pc, out) = (self.kernel, self.resume_pc, self.fiber_result.clone());
1010                let local = local_ptr as usize;
1011                self.call = TaskCall::new(move || {
1012                    out.set(Some(kernel(handle, local as *mut _, pc)));
1013                    0
1014                });
1015                assert!(
1016                    self.call.is_some(),
1017                    "no stack could be mapped for a process fiber"
1018                );
1019            }
1020            let call = self.call.as_mut().expect("created above");
1021            match with_scheduler_ops(ops, || call.poll()) {
1022                CallPoll::Finished(_) => {
1023                    self.call = None;
1024                    self.request = None;
1025                    self.fiber_result
1026                        .take()
1027                        .expect("a finished process fiber left no result")
1028                }
1029                CallPoll::Request(req) => {
1030                    // The C code of an import task wants an export task run:
1031                    // wait for it as the parent of its process.
1032                    let row = self.fork_table.get(req.export as usize).unwrap_or_else(|| {
1033                        panic!(
1034                            "export task {} has no fork-table row (internal)",
1035                            req.export
1036                        )
1037                    });
1038                    let spec = row.clone();
1039                    self.request = Some(req);
1040                    self.joined = true;
1041                    return Suspend::ForkJoin(JoinKind::All, vec![spec]);
1042                }
1043            }
1044        } else {
1045            with_scheduler_ops(ops, || (self.kernel)(handle, local_ptr, self.resume_pc))
1046        };
1047
1048        let mut pc_bytes = [0u8; 4];
1049        pc_bytes.copy_from_slice(
1050            &result.reschedule.opaque[NEXT_RESUME_PC_OFFSET..NEXT_RESUME_PC_OFFSET + 4],
1051        );
1052        self.resume_pc = u32::from_le_bytes(pc_bytes);
1053        self.activation.pc = self.resume_pc;
1054
1055        // `CompiledProcess::resume` (owned by the **sched** track,
1056        // `docs/CONTRACTS.md`'s `vj-sim::sched` ownership table) is
1057        // frozen to return `Suspend`, not `Result` -- changing that trait's
1058        // signature is out of this task's authority (integration rules:
1059        // "do not change a contract owned by another track except
1060        // minimally"). `decode_reschedule`'s `Err` arms are exactly the
1061        // kinds `build_eval_result` above (the only producer of the bytes
1062        // this call ever decodes) never emits -- see `DecodeRescheduleError`'s
1063        // own doc comment -- so this `unreachable!` documents a real,
1064        // checked invariant of THIS call site, not a masked bug; it is
1065        // `decode_reschedule` itself (usable independently, e.g. by a future
1066        // caller that constructs its own `EvalResult`) that must never
1067        // panic, and now never does.
1068        // R-run Task 17: routes through `decode_reschedule_with_fork_table`
1069        // (never the plain `decode_reschedule` directly any more) so a
1070        // `KIND_FORK_JOIN` byte -- now a real, producible encoding, see
1071        // `ResolvedSuspend::ForkJoin` -- resolves against this instance's
1072        // own `self.fork_table` instead of always failing with
1073        // `UnresolvedForkJoin`. For every kernel with no fork/join site,
1074        // `self.fork_table` is empty and this call behaves identically to
1075        // the old direct `decode_reschedule` call (that kind byte is simply
1076        // never produced for such a kernel).
1077        let suspend = decode_reschedule_with_tables(result, &self.fork_table, &self.wait_table)
1078            .unwrap_or_else(|e| {
1079                unreachable!(
1080                    "CompiledKernelProcess::resume: decode_reschedule_with_fork_table returned \
1081                     {e:?}, but build_eval_result (the only producer of RescheduleReq bytes in \
1082                     this tree) never constructs an EdgeList/invalid kind, and a real \
1083                     KIND_FORK_JOIN's (base,count) always indexes this same instance's own \
1084                     fork_table (an internal invariant of how vj-codegen pairs a kernel \
1085                     with its program's fork table -- see CompiledKernelProcess::with_fork_table)"
1086                )
1087            });
1088        self.joined = matches!(
1089            suspend,
1090            Suspend::ForkJoin(JoinKind::All | JoinKind::Any, _)
1091                | Suspend::WaitFork
1092                | Suspend::WaitOrder(_)
1093        );
1094        self.finished = matches!(suspend, Suspend::Done);
1095        if self.finished
1096            && let Some(bx) = self.return_box.take()
1097        {
1098            let words = self
1099                .returns
1100                .iter()
1101                .map(|&(c, p)| {
1102                    let at = c as usize * 8;
1103                    let mut w = [0u8; 8];
1104                    w.copy_from_slice(&self.frame[at..at + 8]);
1105                    (p, u64::from_le_bytes(w))
1106                })
1107                .collect();
1108            *bx.borrow_mut() = Some(words);
1109        }
1110        if self.finished
1111            && let Some((image, n)) = self.image_back.take()
1112        {
1113            // SAFETY: see `mint_fork_child`; the importing fiber is still
1114            // parked on that image.
1115            unsafe {
1116                std::ptr::copy_nonoverlapping(self.frame.as_ptr(), image as *mut u8, n);
1117            }
1118        }
1119        // Codegen encodes a waited-on signal by its TEMPLATE slot (the same
1120        // kernel serves every instance of its module); map it to THIS
1121        // instance's absolute cell here. Without this, every instance waited
1122        // on the first instance's signal (e.g. a second counter instance
1123        // clocked by its own `en` followed the first's `clk`). Only for a
1124        // handle that really is a STATE `InstanceHandle`
1125        // (`with_instance_slot_mapping`, set by `vj run`); unit-test
1126        // kernels run against mock state and keep their cells as given.
1127        if !self.map_slots_to_instance {
1128            return suspend;
1129        }
1130        match suspend {
1131            Suspend::WaitEvents(mut set) => {
1132                for term in &mut set.terms {
1133                    remap_term_to_instance(term, self.sim_handle);
1134                }
1135                Suspend::WaitEvents(set)
1136            }
1137            other => other,
1138        }
1139    }
1140}
1141
1142/// See the remapping note in `CompiledKernelProcess::resume`.
1143fn remap_term_to_instance(term: &mut EventTerm, handle: SimHandle) {
1144    let map = |c: &mut StateCellId| {
1145        // The shared-variables and object-state cells are no instance's slot.
1146        if *c == OBJECT_STATE_CELL {
1147            OBJECT_STATE_WATCHED.store(true, std::sync::atomic::Ordering::Relaxed);
1148        } else if *c != SHARED_VARS_CELL {
1149            *c = crate::state::cell_for_slot(handle, vj_abi::SlotId(c.0 as u32));
1150        }
1151    };
1152    match term {
1153        EventTerm::Edge { cell, .. } | EventTerm::Select { cell, .. } => map(cell),
1154        EventTerm::ImplicitAny(cells) => cells.iter_mut().for_each(map),
1155        EventTerm::NamedEventTrigger(cell) => map(cell),
1156        EventTerm::Repeat { inner, .. } => remap_term_to_instance(inner, handle),
1157    }
1158}
1159
1160/// The delay-ticks value marking a [`ResolvedSuspend::DynamicDelay`]: the
1161/// real value is the one the kernel last stored with
1162/// [`real_sv_rt_set_delay`] (the kernel call and its decode run back to back
1163/// on one thread).
1164const DYNAMIC_DELAY_MARK: u64 = u64::MAX;
1165
1166thread_local! {
1167    static DYNAMIC_DELAY: Cell<u64> = const { Cell::new(0) };
1168}
1169
1170/// `void sv_rt_set_delay(u64 ticks)`: the tick count of the run-time delay
1171/// the calling kernel is about to suspend on.
1172#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_set_delay"))]
1173pub extern "C" fn real_sv_rt_set_delay(ticks: u64) {
1174    DYNAMIC_DELAY.with(|c| c.set(ticks));
1175}
1176
1177thread_local! {
1178    /// The index of the wait term that woke the running kernel (-1 if it
1179    /// was not woken by an event).
1180    static FIRED_TERM: Cell<i64> = const { Cell::new(-1) };
1181    /// Terms (bit k = term k, k < 64) that matched a process's wait since it
1182    /// last resumed, by process; moved to [`FIRED_MASK`] at resume.
1183    /// Indexed by process id (dense, from `register_process`): one load and
1184    /// store per matched term, no hashing, on the wake hot path.
1185    static FIRED_MASKS: std::cell::RefCell<Vec<u64>> = const { std::cell::RefCell::new(Vec::new()) };
1186    /// The resuming kernel's matched-term set (`sv_rt_fired_mask`).
1187    static FIRED_MASK: Cell<u64> = const { Cell::new(0) };
1188    /// Whether the running process ran `$finish`/`$stop`/`$fatal` (directly,
1189    /// in a subroutine, or as a null virtual interface's fatal error).
1190    static ABORTED: Cell<bool> = const { Cell::new(false) };
1191}
1192
1193thread_local! {
1194    /// The cells holding variables a process shares with its fork branches
1195    /// (`sv_rt_cell_alloc`), kept for the whole run.
1196    static CELLS: std::cell::RefCell<Vec<Box<[u64]>>> = const { std::cell::RefCell::new(Vec::new()) };
1197}
1198
1199/// `ptr sv_rt_cell_alloc(u64 bytes)`: a new zeroed cell for a variable a
1200/// process shares with its fork branches (LRM 1800-2023 §9.3.2: the forked
1201/// processes use the forking activation's own variable). One per creation
1202/// of such a variable, alive until the run ends.
1203#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_cell_alloc"))]
1204pub extern "C" fn real_sv_rt_cell_alloc(bytes: u64) -> *mut u8 {
1205    let words = (bytes as usize).div_ceil(8).max(1);
1206    let mut cell: Box<[u64]> = vec![0u64; words].into_boxed_slice();
1207    let ptr = cell.as_mut_ptr().cast::<u8>();
1208    CELLS.with(|c| c.borrow_mut().push(cell));
1209    ptr
1210}
1211
1212/// The template slot a wait encodes for "a variable shared between
1213/// processes changed" (`SHARED_VARS_CELL`; never a real STATE slot).
1214pub const SHARED_VARS_SLOT: u32 = u32::MAX;
1215
1216/// The cell a process waiting on a variable it shares with other processes
1217/// subscribes to: [`real_sv_rt_cell_store`] notifies it when such a variable
1218/// changes (the waiter's guard then re-checks its condition).
1219pub const SHARED_VARS_CELL: crate::state::StateCellId =
1220    crate::state::StateCellId(SHARED_VARS_SLOT as u64);
1221
1222/// The template slot a wait encodes for "class-object state changed": a
1223/// `wait (...)` condition that reads a class property, calls a method of a
1224/// class, or calls `weak_reference::get` / `mailbox::num` reads heap state, and
1225/// a heap change is not a signal change. [`notify_object_state_change`] wakes
1226/// the waiters on it (the resume guard re-checks the condition). Never a real
1227/// STATE slot, like [`SHARED_VARS_SLOT`].
1228pub const OBJECT_STATE_SLOT: u32 = u32::MAX - 1;
1229
1230/// The cell a wait on class-object state subscribes to (see
1231/// [`OBJECT_STATE_SLOT`]).
1232pub const OBJECT_STATE_CELL: crate::state::StateCellId =
1233    crate::state::StateCellId(OBJECT_STATE_SLOT as u64);
1234
1235/// Whether any process has ever suspended on [`OBJECT_STATE_CELL`] in this
1236/// run. Heap writes are frequent; until a wait on object state exists the
1237/// notification is one relaxed load. Never cleared (a later wait may come back).
1238static OBJECT_STATE_WATCHED: std::sync::atomic::AtomicBool =
1239    std::sync::atomic::AtomicBool::new(false);
1240
1241/// Marks object state as watched by a process that is statically sensitive to
1242/// [`OBJECT_STATE_CELL`] (a class covergroup's clocking-event sampler, LRM 19.4)
1243/// rather than suspended on it, so heap writes notify it (see
1244/// [`OBJECT_STATE_WATCHED`]).
1245pub fn watch_object_state() {
1246    OBJECT_STATE_WATCHED.store(true, std::sync::atomic::Ordering::Relaxed);
1247}
1248
1249/// Whether any process has suspended on [`OBJECT_STATE_CELL`] (see
1250/// [`OBJECT_STATE_WATCHED`]); the scheduler's own notification sites (a process
1251/// ending is object state for `wait (p.status() == process::FINISHED)`) test it
1252/// before building a notify sink.
1253pub(crate) fn object_state_watched() -> bool {
1254    OBJECT_STATE_WATCHED.load(std::sync::atomic::Ordering::Relaxed)
1255}
1256
1257/// A class object's observable state changed: a property was written, an
1258/// object was freed (a `weak_reference` to it reads null now), or a mailbox's
1259/// queue changed. Wakes the processes waiting on object state so they re-check
1260/// their conditions. Callers must not hold the objects/mailbox table lock.
1261/// Outside a running kernel (unit tests of the tables) there is nothing to wake.
1262pub fn notify_object_state_change() {
1263    if OBJECT_STATE_WATCHED.load(std::sync::atomic::Ordering::Relaxed) {
1264        with_current_ops(|ops| ops.notify_blocking_row_change(OBJECT_STATE_CELL));
1265    }
1266}
1267
1268/// `void sv_rt_cell_store(ptr cell, ptr value, u64 bytes)`: stores a shared
1269/// variable's value into its cell, waking the processes waiting on shared
1270/// variables if it changed.
1271///
1272/// # Safety
1273/// `cell` and `value` each point to at least `bytes` valid bytes.
1274#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_cell_store"))]
1275pub unsafe extern "C" fn real_sv_rt_cell_store(cell: *mut u8, value: *const u8, bytes: u64) {
1276    // SAFETY: the caller's contract.
1277    let (to, from) = unsafe {
1278        (
1279            std::slice::from_raw_parts_mut(cell, bytes as usize),
1280            std::slice::from_raw_parts(value, bytes as usize),
1281        )
1282    };
1283    if to != from {
1284        to.copy_from_slice(from);
1285        with_current_ops(|ops| ops.notify_blocking_row_change(SHARED_VARS_CELL));
1286    }
1287}
1288
1289thread_local! {
1290    /// The value each event-control term on a shared variable last saw:
1291    /// (waiting frame, site) -> its bits.
1292    static EDGE_SNAPSHOTS: std::cell::RefCell<std::collections::HashMap<(u64, u64), Vec<u8>>> =
1293        std::cell::RefCell::new(std::collections::HashMap::new());
1294}
1295
1296/// [`ELEM_WAITS`]'s table: process -> `(site, array cell, row)` rows.
1297type ElemWaits = std::collections::HashMap<ProcessId, Vec<(u64, StateCellId, u64)>>;
1298
1299thread_local! {
1300    /// Which element of which event array each process waits on
1301    /// (LRM 1800-2023 37.27 detail 1, `vpiWaitingProcesses` of an array
1302    /// element): process -> `(snapshot site, array cell, storage row)`,
1303    /// recorded by the element-term snapshots of its suspend. The scheduler
1304    /// wakes every waiter on the array's one cell; this is the only record
1305    /// of which element each one waits on. A snapshot of another suspend
1306    /// point (the site's high bits) replaces the process's older rows, and a
1307    /// reader intersects with the scheduler's waiters, so a process that has
1308    /// moved on is never reported.
1309    static ELEM_WAITS: std::cell::RefCell<ElemWaits> =
1310        std::cell::RefCell::new(std::collections::HashMap::new());
1311}
1312
1313/// Bit of an event-array element term's snapshot site that marks the snapshot
1314/// as carrying `[row, valid, count, slot, handle]` (40 bytes) rather than a
1315/// bare value: see [`ELEM_WAITS`].
1316pub const ELEM_SITE_BIT: u64 = 1 << 62;
1317
1318/// The processes now waiting on row `row` of the event array in `cell`
1319/// (a subset of the cell's waiters; see [`ELEM_WAITS`]).
1320pub fn event_elem_waiters(cell: StateCellId, row: u64) -> Vec<ProcessId> {
1321    ELEM_WAITS.with(|m| {
1322        let mut v: Vec<ProcessId> = m
1323            .borrow()
1324            .iter()
1325            .filter(|(_, rows)| rows.iter().any(|&(_, c, r)| c == cell && r == row))
1326            .map(|(p, _)| *p)
1327            .collect();
1328        v.sort_unstable();
1329        v
1330    })
1331}
1332
1333/// The `EDGE_SNAPSHOTS` key of (`frame`, `site`). A process whose frame is
1334/// empty gets a null `frame` (`CompiledKernelProcess::resume`), which every
1335/// such process shares: it is keyed by the running process instead (the high
1336/// bit keeps that id apart from any real frame address).
1337fn snapshot_key(frame: u64, site: u64) -> (u64, u64) {
1338    match frame {
1339        0 => (
1340            current_process().map_or(0, |p| (1 << 63) | u64::from(p)),
1341            site,
1342        ),
1343        _ => (frame, site),
1344    }
1345}
1346
1347/// `void sv_rt_edge_snapshot(u64 frame, u64 site, ptr value, u64 bytes)`:
1348/// records the value an event control on a shared variable waits to see
1349/// change (its process's `frame`, the term's `site`).
1350///
1351/// # Safety
1352/// `value` points to at least `bytes` valid bytes.
1353#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_edge_snapshot"))]
1354pub unsafe extern "C" fn real_sv_rt_edge_snapshot(
1355    frame: u64,
1356    site: u64,
1357    value: *const u8,
1358    bytes: u64,
1359) {
1360    // SAFETY: the caller's contract.
1361    let v = unsafe { std::slice::from_raw_parts(value, bytes as usize) }.to_vec();
1362    if site & ELEM_SITE_BIT != 0
1363        && bytes == 40
1364        && let Some(proc) = current_process()
1365    {
1366        let word = |i: usize| u64::from_le_bytes(v[i * 8..i * 8 + 8].try_into().unwrap());
1367        // Same suspend point (site bits above the term index): the wait
1368        // being re-entered replaces its own rows; another one starts over.
1369        let wait_of = |site: u64| site >> 16;
1370        ELEM_WAITS.with(|m| {
1371            let mut m = m.borrow_mut();
1372            let rows = m.entry(proc).or_default();
1373            rows.retain(|&(s, ..)| wait_of(s) == wait_of(site) && s != site);
1374            if word(1) != 0 {
1375                // SAFETY: word 4 is the process's own `SimHandle`, which the
1376                // snapshot's emitter loaded from the accessor context.
1377                let handle = SimHandle(word(4) as usize as *mut std::ffi::c_void);
1378                let cell = crate::state::cell_for_slot(handle, vj_abi::SlotId(word(3) as u32));
1379                rows.push((site, cell, word(0)));
1380            }
1381        });
1382    }
1383    EDGE_SNAPSHOTS.with(|m| m.borrow_mut().insert(snapshot_key(frame, site), v));
1384}
1385
1386/// `i32 sv_rt_edge_check(u64 frame, u64 site, ptr value, u64 bytes, u32
1387/// width, u32 edge)`: whether the shared variable now holding `value` (a
1388/// packed 4-state value of `width` bits: values in bits 0.., unknown flags
1389/// from bit `width`) has made the event `edge` (0 any change, 1 posedge, 2
1390/// negedge, 3 either edge -- LRM 1800-2023 Table 9-2, on the least
1391/// significant bit) since the snapshot, which is then moved to `value`.
1392///
1393/// # Safety
1394/// `value` points to at least `bytes` valid bytes.
1395#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_edge_check"))]
1396pub unsafe extern "C" fn real_sv_rt_edge_check(
1397    frame: u64,
1398    site: u64,
1399    value: *const u8,
1400    bytes: u64,
1401    width: u32,
1402    edge: u32,
1403) -> i32 {
1404    // SAFETY: the caller's contract.
1405    let new = unsafe { std::slice::from_raw_parts(value, bytes as usize) }.to_vec();
1406    let bit = |buf: &[u8], i: usize| buf.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 == 1);
1407    let used_bits = 2 * width as usize;
1408    let changed = |old: &[u8]| (0..used_bits).any(|i| bit(old, i) != bit(&new, i));
1409    let key = snapshot_key(frame, site);
1410    let fired = EDGE_SNAPSHOTS.with(|m| {
1411        let mut m = m.borrow_mut();
1412        let fired = match m.get(&key) {
1413            None => false,
1414            Some(old) => {
1415                // (known 0, known 1, unknown) of the least significant bit.
1416                let state = |buf: &[u8]| {
1417                    let (v, u) = (bit(buf, 0), bit(buf, width as usize));
1418                    (!u && !v, !u && v, u)
1419                };
1420                let (o0, o1, ox) = state(old);
1421                let (n0, n1, nx) = state(&new);
1422                let pos = (o0 && (n1 || nx)) || (ox && n1);
1423                let neg = (o1 && (n0 || nx)) || (ox && n0);
1424                match edge {
1425                    1 => pos,
1426                    2 => neg,
1427                    3 => pos || neg,
1428                    _ => changed(old),
1429                }
1430            }
1431        };
1432        m.insert(key, new.clone());
1433        fired
1434    });
1435    i32::from(fired)
1436}
1437
1438/// `void sv_rt_edge_load(u64 frame, u64 site, ptr out, u64 bytes)`: copies
1439/// the bytes recorded by [`real_sv_rt_edge_snapshot`] for (`frame`, `site`)
1440/// into `out`; all zero when none was recorded (or it is shorter). Unlike
1441/// [`real_sv_rt_edge_check`] it leaves the snapshot in place, so a guard that
1442/// re-suspends keeps comparing against the state at the wait's start.
1443///
1444/// # Safety
1445/// `out` points to at least `bytes` writable bytes.
1446pub unsafe extern "C" fn real_sv_rt_edge_load(frame: u64, site: u64, out: *mut u8, bytes: u64) {
1447    // SAFETY: the caller's contract.
1448    let dst = unsafe { std::slice::from_raw_parts_mut(out, bytes as usize) };
1449    dst.fill(0);
1450    EDGE_SNAPSHOTS.with(|m| {
1451        if let Some(v) = m.borrow().get(&snapshot_key(frame, site)) {
1452            let n = v.len().min(dst.len());
1453            dst[..n].copy_from_slice(&v[..n]);
1454        }
1455    });
1456}
1457
1458/// `void sv_rt_set_fired_term(i64 term)`: overrides which term of the wait
1459/// the resuming kernel reports from [`real_sv_rt_fired_term`]. A resume guard
1460/// of an event list holding array-element terms uses it: the scheduler wakes
1461/// all of them on the array's one cell and names the first, so the guard
1462/// replaces that with the term whose element actually triggered.
1463pub extern "C" fn real_sv_rt_set_fired_term(term: i64) {
1464    FIRED_TERM.with(|c| c.set(term));
1465}
1466
1467/// Frees every cell of the previous run (`vj`'s run start).
1468pub fn reset_cells_for_new_run() {
1469    CELLS.with(|c| c.borrow_mut().clear());
1470    crate::state::reset_driver_counts_for_new_run();
1471    EDGE_SNAPSHOTS.with(|m| m.borrow_mut().clear());
1472    ELEM_WAITS.with(|m| m.borrow_mut().clear());
1473    // Process ids restart from zero: a waiter queued by the previous run must
1474    // not be mistaken for one of this run's processes.
1475    super::semaphore::reset_for_new_run();
1476    super::class_event::reset_for_new_run();
1477    super::mailbox::reset_for_new_run();
1478    crate::sched::proc_assert::reset();
1479    // 20.11: no assertion control task has run in a new run.
1480    crate::sched::assert_control::reset();
1481}
1482
1483/// `i32 sv_rt_aborted()`: 1 if the running process has ended the run
1484/// (`$finish`/`$stop`/`$fatal`, LRM 1800-2023 §20.2/§20.10) since it
1485/// resumed -- code after such a call inside a subroutine must not run, and
1486/// neither may the caller's (`vj-codegen`'s `leave_if_aborted`).
1487#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_aborted"))]
1488pub extern "C" fn real_sv_rt_aborted() -> i32 {
1489    ABORTED.with(|c| i32::from(c.get()))
1490}
1491
1492/// Records that term `term` of `proc`'s event wait matched in the current
1493/// time step (see [`real_sv_rt_fired_mask`]).
1494pub(crate) fn note_fired_term(proc: u32, term: usize) {
1495    if term < 64 {
1496        FIRED_MASKS.with(|m| {
1497            let mut m = m.borrow_mut();
1498            let i = proc as usize;
1499            if i >= m.len() {
1500                m.resize(i + 1, 0);
1501            }
1502            m[i] |= 1u64 << term;
1503        });
1504    }
1505}
1506
1507/// Takes and clears `proc`'s recorded matched-term mask (0 if none).
1508fn take_fired(proc: u32) -> u64 {
1509    FIRED_MASKS.with(|m| {
1510        m.borrow_mut()
1511            .get_mut(proc as usize)
1512            .map_or(0, std::mem::take)
1513    })
1514}
1515
1516/// Takes the terms (bit k = term k) of the calling process's event wait that
1517/// matched in the wake now running; for a native (non-kernel) process such as
1518/// a covergroup sampler, which nothing else drains. 0 outside a process.
1519pub fn take_fired_mask() -> u64 {
1520    current_process().map_or(0, take_fired)
1521}
1522
1523/// `i64 sv_rt_fired_mask()`: every term of the event wait the calling kernel
1524/// resumed from that matched before it ran (bit k = term k), 0 if unknown.
1525/// Two members of one event list can fire in the same wake (LRM 1800-2023
1526/// 9.4.2.3) and `sv_rt_fired_term` names only the first.
1527#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_fired_mask"))]
1528pub extern "C" fn real_sv_rt_fired_mask() -> i64 {
1529    FIRED_MASK.with(|c| c.get()) as i64
1530}
1531
1532/// `i64 sv_rt_fired_term()`: which term of the event wait the calling
1533/// kernel resumed from woke it (`wait_order`), -1 if none.
1534#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_fired_term"))]
1535pub extern "C" fn real_sv_rt_fired_term() -> i64 {
1536    FIRED_TERM.with(|c| c.get())
1537}
1538
1539// --- Task S7: the `SimHandle`-reachability bridge for `nba_enqueue`/
1540// `sv_rt_finish`'s real production bodies. -----------------------------
1541
1542thread_local! {
1543    /// While a compiled kernel's raw `ProcessFn` pointer is executing (inside
1544    /// [`with_scheduler_ops`], called only from [`CompiledKernelProcess::
1545    /// resume`] above), this holds the real `&dyn SchedulerOps` that
1546    /// `resume()` call was given -- the bridge this module's own doc comment
1547    /// (and `vj-codegen::process_lower::declare_rt_finish`'s doc comment)
1548    /// both flag as the still-open "the frozen ABI carries no scheduler-ops
1549    /// parameter, and `SimHandle` reaches only STATE" gap. This task closes
1550    /// it with the minimal, additive hook available: a thread-local set
1551    /// (and restored, not merely cleared, so a hypothetical nested call
1552    /// cannot leak its caller's `ops` early -- v1's single-threaded model
1553    /// never actually nests these calls, but the save/restore discipline
1554    /// costs nothing and removes the need to rely on that).
1555    ///
1556    /// `real_nba_enqueue`/`real_sv_rt_finish` below are its only readers;
1557    /// [`with_scheduler_ops`] is its only writer.
1558    ///
1559    /// Stored as a `'static`-erased trait-object pointer (a thread-local's
1560    /// contents must be `'static`, and `ops`'s real lifetime is only the
1561    /// duration of one `resume()` call) -- the `unsafe` lifetime-erasing
1562    /// transmute this requires lives in [`with_scheduler_ops`] alone, whose
1563    /// own save-and-restore discipline is exactly what keeps that erasure
1564    /// sound (the pointer is never read after the real `ops` it points at
1565    /// stops being alive).
1566    static CURRENT_OPS: RefCell<Option<*const (dyn SchedulerOps + 'static)>> =
1567        const { RefCell::new(None) };
1568
1569    /// Review fix (A2-part2 r1, binding rule 7): the runtime-side lookup
1570    /// this module's own doc comment (and `process_lower::
1571    /// lower_nonblocking_assignment`'s doc comment) flagged as "no committed
1572    /// code implements yet". The frozen `ProcessFn` ABI carries no
1573    /// `ProcessId` parameter at all, so a compiled kernel itself can never
1574    /// pass its own identity to `nba_enqueue`; the *scheduler* (SCHED's own
1575    /// `ResumeDriver::resume_for`/`start`, `resume.rs`) already knows which
1576    /// `ProcessId` it is about to resume before it ever calls `.resume(..)`,
1577    /// so that is the one ambient-state hook this bridge needs -- mirrors
1578    /// [`CURRENT_OPS`] exactly (same save/restore discipline, same "set for
1579    /// the duration of one `resume()` call" scope). [`with_current_process`]
1580    /// is its only writer; [`real_nba_enqueue`] is its only reader.
1581    static CURRENT_PROCESS: RefCell<Option<ProcessId>> = const { RefCell::new(None) };
1582
1583    /// Set while a kernel stages a clocking-block synchronous drive (between
1584    /// the `sv_rt_reactive_drive(1)` / `(0)` calls codegen brackets it with):
1585    /// the nonblocking writes it stages belong to the Re-NBA region (LRM
1586    /// 1800-2023 14.16), not the NBA region.
1587    static REACTIVE_DRIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1588
1589    /// Within an edge-skewed clocking drive: the clock signal's cell, the edge
1590    /// (see [`EdgeWait`]) and the ticks after it (LRM 1800-2023 14.4).
1591    static EDGE_DRIVE: std::cell::Cell<Option<EdgeWait>> = const { std::cell::Cell::new(None) };
1592}
1593
1594/// An edge output skew's wait (`output negedge #1 q`): the drive lands `after`
1595/// ticks past the next `edge` of `cell`.
1596#[derive(Clone, Copy)]
1597pub(super) struct EdgeWait {
1598    pub(super) cell: StateCellId,
1599    pub(super) edge: EdgeKind,
1600    pub(super) after: u64,
1601    /// A drive to the block's own event (counter `cell`, LRM 14.16): it
1602    /// matures now when the event was already triggered in this time step.
1603    pub(super) on_event: bool,
1604}
1605
1606/// Whether the running kernel is inside a clocking-block synchronous drive.
1607pub(super) fn reactive_drive() -> bool {
1608    REACTIVE_DRIVE.with(std::cell::Cell::get)
1609}
1610
1611/// The edge wait of the clocking drive the running kernel is staging, if its
1612/// output skew is an edge.
1613pub(super) fn edge_drive() -> Option<EdgeWait> {
1614    EDGE_DRIVE.with(std::cell::Cell::get)
1615}
1616
1617/// The staging delay a kernel passes for a write that waits on
1618/// [`edge_drive`]'s edge instead of a tick count.
1619pub(super) const EDGE_DELAY: u64 = u64::MAX;
1620
1621/// The real body of `sv_rt_reactive_drive`: `on != 0` opens, `0` closes the
1622/// span in which the kernel's nonblocking writes are a clocking drive staged
1623/// to the Re-NBA region (LRM 1800-2023 14.16). An open call with `edge != 0`
1624/// (1 posedge, 2 negedge, 3 either) is an edge output skew (14.4); `4` waits
1625/// for the next trigger of the clocking block's event (14.16; `clock_slot` is
1626/// its event counter): its writes
1627/// carry [`EDGE_DELAY`] and land `after` ticks past the next such edge of
1628/// `clock_slot`.
1629///
1630/// # Safety
1631/// `handle` must be the running kernel's instance handle.
1632#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_reactive_drive"))]
1633pub unsafe extern "C" fn real_sv_rt_reactive_drive(
1634    handle: SimHandle,
1635    on: u32,
1636    clock_slot: u32,
1637    edge: u32,
1638    after: u64,
1639) {
1640    if on != 0 {
1641        with_current_ops(|ops| ops.note_reactive_drive());
1642    }
1643    REACTIVE_DRIVE.with(|c| c.set(on != 0));
1644    let wait = (on != 0 && edge != 0).then(|| EdgeWait {
1645        cell: crate::state::cell_for_slot(handle, vj_abi::SlotId(clock_slot)),
1646        edge: match edge {
1647            1 => EdgeKind::Posedge,
1648            2 => EdgeKind::Negedge,
1649            _ => EdgeKind::BothEdges,
1650        },
1651        after,
1652        on_event: edge == 4,
1653    });
1654    EDGE_DRIVE.with(|c| c.set(wait));
1655}
1656
1657/// Runs `f` (one `resume()` call, dispatched via `ResumeDriver`) with `proc`
1658/// reachable through [`CURRENT_PROCESS`] for exactly that duration,
1659/// restoring whatever was there before on return -- see that thread-local's
1660/// own doc comment. Called only from `sched::resume::ResumeDriver::
1661/// resume_for`/`start`, the two places `proc` is known before the dispatch
1662/// into `CompiledProcess::resume` happens. (`pub` so a runtime-ABI unit test
1663/// can stand in for a running process -- `vj_runtime::semaphore_rt`.)
1664pub fn with_current_process<R>(proc: ProcessId, f: impl FnOnce() -> R) -> R {
1665    let prev = CURRENT_PROCESS.with(|c| c.replace(Some(proc)));
1666    let result = f();
1667    CURRENT_PROCESS.with(|c| *c.borrow_mut() = prev);
1668    result
1669}
1670
1671/// B-runtime Task 6 review fix r1 (RT-078): the read-only, cross-crate
1672/// half of the same [`CURRENT_PROCESS`] ambient-state bridge
1673/// [`real_nba_enqueue`] already uses to attribute an NBA write to a real
1674/// `ProcessId` despite the frozen `ProcessFn` ABI carrying none.
1675/// `vj_runtime::random_sink`'s `$random`/`$urandom`/`$urandom_range`
1676/// sink has the identical problem -- their ABI symbols
1677/// (`vj_codegen::expr::SV_RT_RANDOM_SYMBOL` et al.) are genuinely
1678/// zero-argument (no `SimHandle`, no `ProcessId`; see that module's own doc
1679/// comment for why), so this is the one already-live ambient hook that can
1680/// supply a real per-process identity to key a seed store by. Returns
1681/// `None` outside of any `ResumeDriver::resume_for`/`start` dispatch
1682/// (exactly [`with_current_process`]'s own "not currently installed" case) --
1683/// callers key an isolated fallback slot for that case, never conflating it
1684/// with a real `ProcessId`.
1685pub fn current_process() -> Option<ProcessId> {
1686    CURRENT_PROCESS.with(|c| *c.borrow())
1687}
1688
1689/// Runs `f` (one raw kernel call) with `ops` reachable through
1690/// [`CURRENT_OPS`] for exactly that duration, restoring whatever was there
1691/// before on return (see that thread-local's own doc comment).
1692pub fn with_scheduler_ops<R>(ops: &dyn SchedulerOps, f: impl FnOnce() -> R) -> R {
1693    let raw: *const dyn SchedulerOps = ops;
1694    // SAFETY: erases `raw`'s real (sub-`'static`, tied to this call's own
1695    // `ops` parameter) lifetime so it can be stored in a `thread_local!`
1696    // (which requires `'static` contents). This is sound only because this
1697    // function itself restores `CURRENT_OPS` to `prev` (see below) before
1698    // returning -- i.e. before `ops`'s real borrow ends -- so no reader
1699    // (`with_current_ops`, called only from inside `f`'s own dynamic extent)
1700    // can ever observe this pointer after the object it points at is no
1701    // longer alive.
1702    let raw_static: *const (dyn SchedulerOps + 'static) = unsafe { std::mem::transmute(raw) };
1703    let prev = CURRENT_OPS.with(|c| c.replace(Some(raw_static)));
1704    let result = f();
1705    CURRENT_OPS.with(|c| *c.borrow_mut() = prev);
1706    result
1707}
1708
1709/// Calls `f` with the current [`CURRENT_OPS`], if any is set. A miss (no
1710/// `with_scheduler_ops` call is currently on this thread's stack) is
1711/// unreachable for every kernel this tree's own codegen emits -- `nba_enqueue`/
1712/// `sv_rt_finish` are only ever invoked from inside a JIT'd kernel body, which
1713/// is only ever invoked from [`CompiledKernelProcess::resume`], which always
1714/// wraps the call in [`with_scheduler_ops`] -- so a miss here is silently
1715/// doing nothing for a call shape nothing in this tree can actually produce,
1716/// not a masked bug.
1717pub fn with_current_ops(f: impl FnOnce(&dyn SchedulerOps)) {
1718    // The pointer is copied out so the `RefCell` borrow ends before `f` runs:
1719    // `f` may itself enter `with_scheduler_ops` (an assertion action block
1720    // resumed from a Reactive callback, plan T29), which replaces the cell.
1721    let raw = CURRENT_OPS.with(|c| *c.borrow());
1722    if let Some(raw) = raw {
1723        // SAFETY: `raw` is only ever `Some` while the `resume()` call
1724        // that set it via `with_scheduler_ops` is still executing on
1725        // this thread (see that function's own body: it clears/restores
1726        // before returning), and that call's own `ops: &dyn
1727        // SchedulerOps` argument is alive for its whole duration -- so
1728        // this pointer is live for exactly as long as it can be read.
1729        let ops: &dyn SchedulerOps = unsafe { &*raw };
1730        f(ops);
1731    }
1732}
1733
1734/// Task S7's real production body for the frozen `nba_enqueue` ABI symbol
1735/// (`vj_abi::process::NbaEnqueueFn`/`NBA_ENQUEUE_SYMBOL`) -- the ONE place
1736/// a nonblocking write a compiled kernel stages reaches the scheduler's real
1737/// [`SchedulerOps::stage_nba`]. Bound into a real JIT'd module via
1738/// `Jit::add_global_mapping(nba_fv, real_nba_enqueue as *const () as usize)`
1739/// (`vj::run`) -- no `#[no_mangle]`/linker-visible symbol is needed,
1740/// since `add_global_mapping` binds by explicit address, exactly like every
1741/// accessor-ABI stub this workspace's own tests already bind this same way.
1742#[cfg_attr(feature = "aot-exports", unsafe(export_name = "nba_enqueue"))]
1743pub extern "C" fn real_nba_enqueue(
1744    handle: SimHandle,
1745    slot: vj_abi::SlotId,
1746    value: u128,
1747    source: u32,
1748) {
1749    // SAFETY: identical justification to `state::accessor_impl::
1750    // sv_get_scalar` -- every real caller of this ABI-named symbol (the
1751    // JIT'd kernel `CompiledKernelProcess::resume` invokes) is handed a
1752    // `SimHandle` minted by `InstanceHandle::into_sim_handle`.
1753    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
1754    let cell = ih.cell_for_slot(slot.0 as usize);
1755    // Review fix (A2-part2 r1, binding rule 7): `source` (the frozen ABI's
1756    // own `u32` parameter) is always the codegen's constant `0` --
1757    // `process_lower::lower_nonblocking_assignment`'s own doc comment
1758    // explains why the frozen ABI has nowhere to carry a real `ProcessId`.
1759    // [`CURRENT_PROCESS`] is the real attribution: `ResumeDriver::
1760    // resume_for`/`start` (`sched::resume`) set it, via
1761    // [`with_current_process`], for the exact duration of the `resume()`
1762    // call that is (transitively, through this very kernel invocation)
1763    // calling this function right now, so a hit here is the ProcessId
1764    // actually being resumed. A miss (`None`) falls back to the raw ABI
1765    // value -- reachable only for the handful of this module's own unit
1766    // tests that call `CompiledKernelProcess::resume` directly, bypassing
1767    // `ResumeDriver` entirely; every real `vj run` dispatch goes
1768    // through `ResumeDriver`, so production code always hits `Some`.
1769    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(source);
1770    with_current_ops(|ops| ops.stage_nba(cell, FourStateValue::Narrow(value), source));
1771}
1772
1773/// A nonblocking write to part of a signal (`q[3:0] <= v`), to a memory row
1774/// (`mem[i] <= v`, `has_row != 0`), or to a >64-bit signal: stages
1775/// `(cell, row, value, mask)` for the NBA commit (`nba::MaskedNbaUpdate`).
1776/// `val`/`unk`/`mask` each point at `nwords` words holding the target's
1777/// `width` bits in codegen's val/unk planes, the new bits already shifted
1778/// into position; only the bits set in `mask` are written. A nonzero `delay`
1779/// (`q <= #d v`) defers the write to the time step `delay` ticks away.
1780///
1781/// # Safety
1782/// `val`, `unk` and `mask` must each point at `nwords` readable `u64`s, and
1783/// `nwords` must be `width.div_ceil(64)` (codegen's `build_nba_masked`).
1784#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_nba_masked"))]
1785pub unsafe extern "C" fn real_sv_rt_nba_masked(
1786    handle: SimHandle,
1787    slot: u32,
1788    row: u64,
1789    has_row: u32,
1790    val: *const u64,
1791    unk: *const u64,
1792    mask: *const u64,
1793    nwords: u32,
1794    width: u32,
1795    delay: u64,
1796) {
1797    // SAFETY: identical justification to `real_nba_enqueue` above.
1798    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
1799    let cell = ih.cell_for_slot(slot as usize);
1800    let n = nwords as usize;
1801    // SAFETY: the caller's contract (this function's `# Safety`).
1802    let (val, unk, mask) = unsafe {
1803        (
1804            std::slice::from_raw_parts(val, n).to_vec(),
1805            std::slice::from_raw_parts(unk, n).to_vec(),
1806            std::slice::from_raw_parts(mask, n).to_vec(),
1807        )
1808    };
1809    // STATE's encodings: `Narrow` is val / unk (unk at bit 64); `Wide` is
1810    // IEEE aval / bval, aval = val ^ unk.
1811    let value = if width <= 64 {
1812        FourStateValue::Narrow(u128::from(val[0]) | (u128::from(unk[0]) << 64))
1813    } else {
1814        FourStateValue::Wide {
1815            aval: val.iter().zip(&unk).map(|(v, u)| v ^ u).collect(),
1816            bval: unk,
1817        }
1818    };
1819    let row = (has_row != 0).then_some(row);
1820    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
1821    with_current_ops(|ops| {
1822        if delay == 0 {
1823            ops.stage_nba_masked(cell, row, value, mask, source);
1824        } else {
1825            ops.stage_nba_masked_delayed(cell, row, value, mask, source, delay);
1826        }
1827    });
1828}
1829
1830/// The real body of `sv_rt_nba_coll`: a nonblocking write of element
1831/// `index` of the queue / dynamic array whose handle is held by `slot` (row
1832/// `row` when `has_row != 0`), LRM 1800-2023 10.4.2 / 7.10. `elem` points at
1833/// `elem_words` words (the element's packed bits); `flags` bit 0 is a queue
1834/// (an index equal to the size appends), bit 1 an object-handle element. The
1835/// handle is looked up and the index checked when the update commits, so an
1836/// out-of-range write is ignored then. `delay` ticks defer the staging.
1837/// A non-null `key` (`key_kind`/`key_width` as in `sv_rt_coll`) selects the
1838/// element of an associative array by key instead of `index`.
1839///
1840/// # Safety
1841/// `elem` must point at `elem_words` readable `u64`s, `key` (when non-null)
1842/// at the key's words.
1843#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_nba_coll"))]
1844pub unsafe extern "C" fn real_sv_rt_nba_coll(
1845    handle: SimHandle,
1846    slot: u32,
1847    row: u64,
1848    has_row: u32,
1849    index: i64,
1850    flags: u32,
1851    elem: *const u64,
1852    elem_words: u32,
1853    delay: u64,
1854    key: *const u64,
1855    key_kind: u32,
1856    key_width: u32,
1857) {
1858    // SAFETY: identical justification to `real_nba_enqueue` above.
1859    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
1860    let cell = ih.cell_for_slot(slot as usize);
1861    // SAFETY: the caller's contract (this function's `# Safety`).
1862    let elem = unsafe { std::slice::from_raw_parts(elem, elem_words as usize) }.to_vec();
1863    let obj = flags & 2 != 0;
1864    let mut shape = crate::collections::Shape {
1865        elem_words: elem_words as usize,
1866        assoc: false,
1867        key: crate::collections::KeyKind::Int {
1868            width: 0,
1869            signed: false,
1870        },
1871        obj,
1872        nested: crate::collections::Nested::NONE,
1873    };
1874    // An associative array's key is decoded now (a string key is a handle
1875    // to text that may change before the update lands).
1876    let mut key_val = None;
1877    if !key.is_null() {
1878        shape.assoc = true;
1879        shape.key = if key_kind == 2 {
1880            crate::collections::KeyKind::Str
1881        } else if key_kind == 3 {
1882            crate::collections::KeyKind::Obj
1883        } else {
1884            crate::collections::KeyKind::Int {
1885                width: key_width,
1886                signed: key_kind == 1,
1887            }
1888        };
1889        let kw = if key_kind == 2 {
1890            1
1891        } else {
1892            (2 * key_width as usize).div_ceil(64).max(1)
1893        };
1894        // SAFETY: the caller's contract: `key` points at the key's words.
1895        let bits = unsafe { std::slice::from_raw_parts(key, kw) };
1896        match shape.key_from_bits(bits) {
1897            Some(k) => key_val = Some(k),
1898            // An invalid key (x/z bits) writes nothing (LRM 7.8.6).
1899            None => {
1900                shape.discard(&elem);
1901                return;
1902            }
1903        }
1904    }
1905    let coll = super::nba::CollWrite {
1906        shape,
1907        index,
1908        key: key_val,
1909        queue: flags & 1 != 0,
1910    };
1911    let row = (has_row != 0).then_some(row);
1912    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
1913    with_current_ops(|ops| ops.stage_nba_coll(cell, row, elem, coll, source, delay));
1914}
1915
1916/// Called by `sv_rt_obj`'s `FieldNba` (no symbol of its own): the real body of
1917/// a nonblocking class-property write: a nonblocking write of the `mask` bits
1918/// of the `words` packed words at `offset` of object `h`'s property storage
1919/// (`obj.p <= v`, `obj.v[3:0] <= v`; LRM 1800-2023 10.4.2, 8.x). The object is
1920/// retained until the NBA region commits it, so a handle overwritten or
1921/// dropped in between does not lose the update. A null or dead `h` stages
1922/// nothing. `delay` ticks defer the staging.
1923///
1924/// # Safety
1925/// `val` and `mask` must each point at `words` readable `u64`s.
1926pub unsafe extern "C" fn real_sv_rt_nba_field(
1927    h: u64,
1928    offset: u64,
1929    words: u32,
1930    val: *const u64,
1931    mask: *const u64,
1932    delay: u64,
1933) {
1934    if !crate::objects::is_live(h) {
1935        return;
1936    }
1937    let n = words as usize;
1938    // SAFETY: the caller's contract (this function's `# Safety`).
1939    let (val, mask) = unsafe {
1940        (
1941            std::slice::from_raw_parts(val, n).to_vec(),
1942            std::slice::from_raw_parts(mask, n).to_vec(),
1943        )
1944    };
1945    let w = super::nba::ObjWrite::new(h, offset as usize, val, mask);
1946    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
1947    with_current_ops(|ops| ops.stage_nba_obj(w, source, delay));
1948}
1949
1950/// A nonblocking trigger (`->> e`, `->> #d e`; LRM 1800-2023 15.5.1) of class
1951/// event `id` (`sched::class_event`), `delay` ticks from now: staged like a
1952/// class-property write and committed in the NBA region, where it wakes the
1953/// event's waiters. A `null` id triggers nothing (15.5.5.2).
1954pub fn real_sv_rt_nba_class_event(id: u64, delay: u64) {
1955    if id == 0 {
1956        return;
1957    }
1958    let w = super::nba::ObjWrite::class_event(id);
1959    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
1960    with_current_ops(|ops| ops.stage_nba_obj(w, source, delay));
1961}
1962
1963/// The real body of `sv_rt_nba_pulse`: a nonblocking event trigger
1964/// (`->> e`, `->> #d e`; LRM 1800-2023 15.5.2) of the event in `slot` (row
1965/// `row` of an event array when `has_row != 0`), `delay` ticks from now. The
1966/// trigger lands in the NBA region of its step (`NbaLog::stage_pulse`).
1967///
1968/// # Safety
1969/// `handle` must be a live `SimHandle` of the running instance.
1970#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_nba_pulse"))]
1971pub unsafe extern "C" fn real_sv_rt_nba_pulse(
1972    handle: SimHandle,
1973    slot: u32,
1974    row: u64,
1975    has_row: u32,
1976    delay: u64,
1977) {
1978    // SAFETY: identical justification to `real_nba_enqueue` above.
1979    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
1980    let cell = ih.cell_for_slot(slot as usize);
1981    let row = (has_row != 0).then_some(row);
1982    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
1983    with_current_ops(|ops| ops.stage_nba_pulse(cell, row, source, delay));
1984}
1985
1986/// Task S7 finding: the real production body for the frozen `sv_set_scalar`
1987/// accessor-ABI symbol (`vj_abi::accessor::SV_SET_SCALAR_SYMBOL`), for a
1988/// COMPILED-KERNEL caller running under a real `Engine` -- deliberately NOT
1989/// `state::accessor_impl::sv_set_scalar` itself (that fn writes straight into
1990/// STATE's arena via `commit_write_at`'s own `&mut ()` no-op sink, which is
1991/// exactly correct for a debugger/VPI-style direct poke, but never notifies
1992/// `Engine`'s sensitivity table -- verified empirically while building this
1993/// task's own end-to-end pipeline: a compiled kernel's blocking write to a
1994/// clock signal through the bare accessor never woke a process waiting on its
1995/// posedge, because nothing told `Engine` the value had changed). A blocking
1996/// (`=`) write from a compiled process MUST go through
1997/// [`SchedulerOps::commit_blocking_write`] instead -- the same real path
1998/// `EngineOps::commit_blocking_write` (`sched::control`) already uses for
1999/// every OTHER blocking-write producer this crate has, which itself performs
2000/// the STATE write (through `Engine`'s own held `SignalAccess`) AND the
2001/// sensitivity-table notification together, as one atomic step. This
2002/// function therefore does NOT also call the bare `sv_set_scalar` -- doing so
2003/// would be a redundant second write through an unrelated path, not a
2004/// correctness requirement.
2005///
2006/// Bound into a real JIT'd module via `Jit::add_global_mapping(set_scalar_fv,
2007/// real_sv_set_scalar as *const () as usize)` (`vj::run`), in place
2008/// of the bare accessor -- the same binding mechanism [`real_nba_enqueue`]
2009/// above uses.
2010#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_set_scalar"))]
2011pub extern "C" fn real_sv_set_scalar(handle: SimHandle, slot: vj_abi::SlotId, value: u128) {
2012    // SAFETY: identical justification to `real_nba_enqueue` above.
2013    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
2014    let cell = ih.cell_for_slot(slot.0 as usize);
2015    with_current_ops(|ops| ops.commit_blocking_write(cell, FourStateValue::Narrow(value)));
2016}
2017
2018/// [`real_sv_set_scalar`]'s wide-tier sibling, bound in place of the bare
2019/// `state::sv_set_wide` for the identical reason: the bare accessor writes
2020/// STATE but never tells `Engine`, so a process waiting on a >64-bit signal
2021/// (`@w`, `wait (w == ...)`, a continuous assign or `always @*` reading it)
2022/// was never woken.
2023#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_set_wide"))]
2024pub extern "C" fn real_sv_set_wide(
2025    handle: SimHandle,
2026    slot: vj_abi::SlotId,
2027    value: vj_abi::WideView,
2028) {
2029    // SAFETY: identical justification to `real_nba_enqueue` above.
2030    let ih = unsafe { InstanceHandle::from_sim_handle(handle) };
2031    let cell = ih.cell_for_slot(slot.0 as usize);
2032    let n = value.word_count as usize;
2033    // SAFETY: per the accessor-ABI contract, a `SvSetWideFn` caller's
2034    // `WideView` points at `word_count` initialized words in each plane for
2035    // the duration of the call (codegen's `build_set_wide` passes its own
2036    // stack buffers sized to the slot).
2037    let aval = unsafe { std::slice::from_raw_parts(value.aval, n) }.to_vec();
2038    let bval = unsafe { std::slice::from_raw_parts(value.bval, n) }.to_vec();
2039    with_current_ops(|ops| ops.commit_blocking_write(cell, FourStateValue::Wide { aval, bval }));
2040}
2041
2042/// `sv_rt_huge`'s operation codes (`vj-codegen::huge`'s `HugeRt`).
2043pub const HUGE_OP_PRIME: u32 = 0;
2044pub const HUGE_OP_MARK: u32 = 1;
2045pub const HUGE_OP_COMMIT: u32 = 2;
2046pub const HUGE_OP_NBA: u32 = 3;
2047
2048/// The one runtime entry for a compiled process's huge-signal shadow
2049/// (`state::huge_mirror`), `void* sv_rt_huge(u32 op, SimHandle, u32 slot,
2050/// u32 width, i64 a, i64 b, const u64* p, u64 delay)`:
2051///
2052/// * [`HUGE_OP_PRIME`]: the signal's long-lived PK image, current, at a stable
2053///   address (returned); `a != 0` re-reads the planes (external code wrote
2054///   them).
2055/// * [`HUGE_OP_MARK`]: widens the image's dirty window by the value bits
2056///   `[a, a + b)`.
2057/// * [`HUGE_OP_COMMIT`]: stores the dirty window to STATE and wakes the
2058///   processes sensitive to the signal iff its value changed.
2059///
2060/// * [`HUGE_OP_NBA`]: stages the nonblocking write `sig[a +: b] <= image`,
2061///   `p` the PK image of the `b`-bit right-hand side (val plane at bit 0, unk
2062///   plane at bit `b`), `delay` its intra-assignment delay; only the plane
2063///   words the select covers are staged.
2064///
2065/// `p` and `delay` are ignored by the other operations. Other operations
2066/// return null.
2067///
2068/// # Safety
2069/// For [`HUGE_OP_NBA`], `p` must point at `ceil(2 * b / 64)` readable words.
2070#[allow(clippy::too_many_arguments)]
2071#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_huge"))]
2072pub unsafe extern "C" fn real_sv_rt_huge(
2073    op: u32,
2074    handle: SimHandle,
2075    slot: u32,
2076    width: u32,
2077    a: i64,
2078    b: i64,
2079    p: *const u64,
2080    delay: u64,
2081) -> *mut u64 {
2082    match op {
2083        HUGE_OP_NBA => {
2084            if b > 0 && !p.is_null() {
2085                // SAFETY: the caller's contract (this function's `# Safety`).
2086                let img = unsafe { std::slice::from_raw_parts(p, (2 * b as usize).div_ceil(64)) };
2087                if let Some(w) =
2088                    crate::state::huge_nba_window(handle, vj_abi::SlotId(slot), width, a, b, img)
2089                {
2090                    let value = FourStateValue::Wide {
2091                        aval: w.aval,
2092                        bval: w.bval,
2093                    };
2094                    let source = CURRENT_PROCESS.with(|c| *c.borrow()).unwrap_or(0);
2095                    with_current_ops(|ops| {
2096                        ops.stage_nba_window(w.cell, w.window, value, w.mask, source, delay);
2097                    });
2098                }
2099            }
2100            std::ptr::null_mut()
2101        }
2102        HUGE_OP_PRIME => crate::state::huge_prime(handle, vj_abi::SlotId(slot), width, a != 0),
2103        HUGE_OP_MARK => {
2104            crate::state::huge_mark(handle, vj_abi::SlotId(slot), a, b);
2105            std::ptr::null_mut()
2106        }
2107        HUGE_OP_COMMIT => {
2108            huge_commit_and_notify(handle, slot, width);
2109            std::ptr::null_mut()
2110        }
2111        _ => std::ptr::null_mut(),
2112    }
2113}
2114
2115/// Commits a huge signal's dirty window to STATE and wakes the processes
2116/// sensitive to it iff the value changed (`state::huge_commit`): the windowed
2117/// stand-in for `build_set_huge` + [`real_sv_set_wide`].
2118fn huge_commit_and_notify(handle: SimHandle, slot: u32, width: u32) {
2119    use crate::state::HugeCommit;
2120    let slot = vj_abi::SlotId(slot);
2121    let wants = |cell| {
2122        let answer = std::cell::Cell::new(false);
2123        with_current_ops(|ops| answer.set(ops.wants_delta(cell)));
2124        answer.get()
2125    };
2126    match crate::state::huge_commit(handle, slot, width, &wants) {
2127        HugeCommit::Nothing | HugeCommit::Unchanged => {}
2128        HugeCommit::Changed {
2129            cell,
2130            old,
2131            new,
2132            delta,
2133        } => {
2134            let edge = crate::sched::edge_between(old, new);
2135            with_current_ops(|ops| ops.notify_blocking_wide_change(cell, edge, delta));
2136        }
2137        HugeCommit::Full {
2138            cell,
2139            value,
2140            revalidate,
2141        } => {
2142            with_current_ops(|ops| ops.commit_blocking_write(cell, value));
2143            crate::state::huge_commit_full_done(handle, slot, revalidate);
2144        }
2145    }
2146}
2147
2148/// [`real_sv_set_scalar`]'s memory-row sibling: commits the row through
2149/// STATE's own row path, then wakes processes sensitive to the memory iff
2150/// the row changed (`SchedulerOps::notify_blocking_row_change`).
2151#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_set_mem_word"))]
2152pub extern "C" fn real_sv_set_mem_word(
2153    handle: SimHandle,
2154    slot: vj_abi::SlotId,
2155    row_index: u64,
2156    value: vj_abi::WideView,
2157) {
2158    let (handle, slot) = crate::state::resolve_foreign_slot(handle, slot);
2159    let (cell, changed) =
2160        crate::state::set_mem_word_reporting_change(handle, slot, row_index, value);
2161    if changed {
2162        with_current_ops(|ops| ops.notify_blocking_row_change(cell));
2163    }
2164}
2165
2166/// Task S7's real production body for the frozen `sv_rt_finish` ABI symbol
2167/// (`vj-codegen::process_lower::SV_RT_FINISH_SYMBOL`/
2168/// `declare_rt_finish`) -- reaches the scheduler's real
2169/// [`SchedulerOps::request_finish`], the one canonical path
2170/// `Engine::run_until` already checks once per timestep (SC-075). Bound the
2171/// same way as [`real_nba_enqueue`] above.
2172#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_finish"))]
2173pub extern "C" fn real_sv_rt_finish(_handle: SimHandle, code: i32) {
2174    ABORTED.with(|c| c.set(true));
2175    with_current_ops(|ops| ops.request_finish(code));
2176}
2177
2178/// `disable fork;` (LRM 9.6.3, SC-125): the real production body for
2179/// `sv_rt_disable_fork`. Kills every live descendant the CURRENT process
2180/// forked, through [`SchedulerOps::disable_fork`] -- reached via the same
2181/// [`CURRENT_OPS`]/[`CURRENT_PROCESS`] bridges as [`real_sv_rt_finish`]/
2182/// [`real_nba_enqueue`] (the frozen `ProcessFn` ABI carries no process id).
2183#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_disable_fork"))]
2184pub extern "C" fn real_sv_rt_disable_fork(_handle: SimHandle) {
2185    if let Some(proc) = current_process() {
2186        with_current_ops(|ops| ops.disable_fork(proc));
2187    }
2188}
2189
2190/// `disable f;` in a branch of fork `f`: the real production body for
2191/// `sv_rt_disable_fork_block` ([`SchedulerOps::disable_fork_block`]).
2192#[cfg_attr(
2193    feature = "aot-exports",
2194    unsafe(export_name = "sv_rt_disable_fork_block")
2195)]
2196pub extern "C" fn real_sv_rt_disable_fork_block(_handle: SimHandle) {
2197    if let Some(proc) = current_process() {
2198        with_current_ops(|ops| ops.disable_fork_block(proc));
2199    }
2200}
2201
2202/// A cross-process/fork-sibling `disable <label>;` (LRM 9.6.2): the real
2203/// production body for `sv_rt_disable_named` ([`SchedulerOps::
2204/// disable_named`]). `label` is a NUL-terminated C string (same convention
2205/// as `vj::dump::sv_rt_dump_vars`'s own `scopes` argument) -- a
2206/// static string literal `vj-codegen::process_lower` builds from the
2207/// disable statement's own source text, never attacker/user-controlled
2208/// binary data, so a `CStr`/UTF-8 decode failure here is an internal
2209/// codegen bug, surfaced loudly (`expect`), never swallowed. Sets this
2210/// kernel's own abort flag ([`abort_current_kernel`]) when the call ends
2211/// the CALLER itself (it is, or descends from, one of the named live
2212/// processes) -- `vj-codegen`'s caller then calls `leave_if_aborted`
2213/// right after, the same two-step `process::kill()` already uses.
2214///
2215/// # Safety
2216/// `label` must point to a valid, NUL-terminated, UTF-8 C string for the
2217/// duration of this call -- guaranteed by codegen always passing a `'static`
2218/// global string constant it compiled from the source `disable` statement.
2219#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_disable_named"))]
2220pub unsafe extern "C" fn real_sv_rt_disable_named(
2221    _handle: SimHandle,
2222    label: *const std::ffi::c_char,
2223) {
2224    // SAFETY: the caller's contract (this function's own doc comment).
2225    let label = unsafe { CStr::from_ptr(label) }
2226        .to_str()
2227        .expect("sv_rt_disable_named: codegen must pass a valid UTF-8 C string");
2228    if let Some(proc) = current_process() {
2229        let mut ends_caller = false;
2230        with_current_ops(|ops| ends_caller = ops.disable_named(proc, label));
2231        if ends_caller {
2232            abort_current_kernel();
2233        }
2234    }
2235}
2236
2237/// Classes plan Task 13 (LRM 9.7): the `process` class's scheduler bridge,
2238/// called by `vj_runtime::process_class`. Like `real_sv_rt_disable_fork`
2239/// it reaches the scheduler through [`CURRENT_OPS`]/[`CURRENT_PROCESS`]
2240/// (the frozen `ProcessFn` ABI carries no process id). All four are no-ops
2241/// (or report `Running`) outside a kernel call.
2242///
2243/// Queues `req` for the calling process; `true` iff it terminates the
2244/// caller itself (the caller then leaves its kernel at once -- see
2245/// [`abort_current_kernel`]).
2246pub fn process_class_request(req: ProcessRequest) -> bool {
2247    let Some(requester) = current_process() else {
2248        return false;
2249    };
2250    let mut ends_caller = false;
2251    with_current_ops(|ops| ends_caller = ops.process_request(requester, req));
2252    ends_caller
2253}
2254
2255/// `p.status()` for process `target`.
2256pub fn process_class_state(target: ProcessId) -> ProcessState {
2257    let mut state = ProcessState::Running;
2258    with_current_ops(|ops| state = ops.process_state(target));
2259    state
2260}
2261
2262/// Marks the calling process as one a `process` handle exists for and
2263/// returns its id (`process::self()`), or `None` outside a process.
2264pub fn process_class_track_current() -> Option<ProcessId> {
2265    let proc = current_process()?;
2266    with_current_ops(|ops| ops.process_track(proc));
2267    Some(proc)
2268}
2269
2270/// Makes the running kernel's next `leave_if_aborted` check fire, which ends
2271/// the calling process's segment (`Suspend::Done`) -- the one mechanism
2272/// `$finish` uses, reused here for a process that killed itself.
2273pub fn abort_current_kernel() {
2274    ABORTED.with(|c| c.set(true));
2275}
2276
2277/// A `width`-bit window at bit `lo` of a `total`-bit cell: the value (from
2278/// codegen's `val`/`unk` lanes, `n` words each, LSB aligned) in the cell's
2279/// own shape, and the window's mask words.
2280///
2281/// # Safety
2282/// `val`/`unk` address `width.div_ceil(64)` words each.
2283unsafe fn force_window(
2284    total: u32,
2285    wide: bool,
2286    lo: u32,
2287    width: u32,
2288    val: *const u64,
2289    unk: *const u64,
2290) -> (FourStateValue, Vec<u64>) {
2291    let n = width.div_ceil(64).max(1) as usize;
2292    // SAFETY: the caller's contract.
2293    let (val, unk) = unsafe {
2294        (
2295            std::slice::from_raw_parts(val, n),
2296            std::slice::from_raw_parts(unk, n),
2297        )
2298    };
2299    let words = total.div_ceil(64).max(1) as usize;
2300    let (mut v, mut u, mut m) = (vec![0u64; words], vec![0u64; words], vec![0u64; words]);
2301    for i in 0..width {
2302        let (src, dst) = (i as usize, (lo + i) as usize);
2303        if dst >= total as usize {
2304            break;
2305        }
2306        let bit = |w: &[u64]| (w[src / 64] >> (src % 64)) & 1;
2307        v[dst / 64] |= bit(val) << (dst % 64);
2308        u[dst / 64] |= bit(unk) << (dst % 64);
2309        m[dst / 64] |= 1 << (dst % 64);
2310    }
2311    let value = if wide {
2312        FourStateValue::Wide {
2313            aval: v.iter().zip(&u).map(|(a, b)| a ^ b).collect(),
2314            bval: u,
2315        }
2316    } else {
2317        FourStateValue::Narrow(u128::from(v[0]) | (u128::from(u[0]) << 64))
2318    };
2319    (value, m)
2320}
2321
2322/// [`crate::state::slot_state`], reporting (and aborting the running process
2323/// on) a slot `force` cannot reach.
2324fn slot_state_or_abort(
2325    handle: SimHandle,
2326    slot: vj_abi::SlotId,
2327) -> Option<(StateCellId, u32, bool, FourStateValue, u32)> {
2328    match crate::state::slot_state(handle, slot) {
2329        Ok(state) => Some(state),
2330        Err(what) => {
2331            // A refused force must not look like a run that went on: end the
2332            // simulation with a failing exit code (as `$finish(1)` does).
2333            eprintln!("vj: runtime error: {what} is not supported");
2334            real_sv_rt_finish(handle, 1);
2335            None
2336        }
2337    }
2338}
2339
2340/// A flag bit of `sv_rt_force`'s `is_net` and `sv_rt_release`'s `flags`: the
2341/// statement is a procedural `assign`/`deassign`, not a `force`/`release`.
2342pub const FORCE_PROCEDURAL_ASSIGN: u32 = 2;
2343
2344/// A flag bit of `sv_rt_force`'s `is_net`: a `force`'s continuous driver
2345/// re-applying it after an operand changed (LRM 10.6.2), not the statement
2346/// executing -- no force event.
2347pub const FORCE_REFRESH: u32 = 4;
2348
2349/// A force, release, procedural assign or deassign, as reported to an
2350/// embedder that asked for them ([`record_force_events`]): VPI's
2351/// `cbForce`/`cbRelease`/`cbAssign`/`cbDeassign` (IEEE 1800-2023 38.36.1).
2352#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2353pub struct ForceEvent {
2354    /// The forced object's STATE cell.
2355    pub cell: StateCellId,
2356    pub release: bool,
2357    /// `assign`/`deassign` rather than `force`/`release`.
2358    pub procedural: bool,
2359}
2360
2361thread_local! {
2362    /// `Some` while an embedder records force events.
2363    static FORCE_EVENTS: RefCell<Option<Vec<ForceEvent>>> = const { RefCell::new(None) };
2364}
2365
2366/// Starts (or stops) recording force events for [`take_force_events`].
2367pub fn record_force_events(on: bool) {
2368    FORCE_EVENTS.with(|e| *e.borrow_mut() = on.then(Vec::new));
2369}
2370
2371/// The force events since the last call, in order.
2372pub fn take_force_events() -> Vec<ForceEvent> {
2373    FORCE_EVENTS.with(|e| {
2374        e.borrow_mut()
2375            .as_mut()
2376            .map(std::mem::take)
2377            .unwrap_or_default()
2378    })
2379}
2380
2381fn note_force(ev: ForceEvent) {
2382    FORCE_EVENTS.with(|e| {
2383        if let Some(v) = e.borrow_mut().as_mut() {
2384            v.push(ev);
2385        }
2386    });
2387}
2388
2389/// `force lhs = v;` (LRM 1800-2023 §10.6.2): forces the `width`-bit window
2390/// at bit `lo` of `slot` to `v` (codegen's `val`/`unk` lanes) on behalf of
2391/// the `force` statement at source offset `site` (`crate::state::force`).
2392/// `flags`: bit 0 for a net (released, it goes back to its drivers), plus
2393/// [`FORCE_PROCEDURAL_ASSIGN`] and [`FORCE_REFRESH`].
2394///
2395/// # Safety
2396/// `val`/`unk` address `width.div_ceil(64)` words each.
2397#[allow(clippy::too_many_arguments)]
2398#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_force"))]
2399pub unsafe extern "C" fn real_sv_rt_force(
2400    handle: SimHandle,
2401    slot: vj_abi::SlotId,
2402    lo: u32,
2403    width: u32,
2404    val: *const u64,
2405    unk: *const u64,
2406    flags: u32,
2407    site: u64,
2408) {
2409    if let Some((row, lo_base)) = crate::state::slot_row_alias(handle, slot) {
2410        // A ref port bound to an element of an unpacked array (LRM
2411        // 23.3.3.2): the force is the element's. A ref port binds a
2412        // variable, never a net, so the net bit the formal's own flags may
2413        // carry does not apply (a released variable keeps its value).
2414        // SAFETY: the caller's contract.
2415        unsafe {
2416            force_row_impl(
2417                handle,
2418                slot,
2419                row,
2420                lo + lo_base,
2421                width,
2422                val,
2423                unk,
2424                flags & !1,
2425                site,
2426            );
2427        }
2428        return;
2429    }
2430    let Some((cell, total, wide, current, lo_base)) = slot_state_or_abort(handle, slot) else {
2431        return;
2432    };
2433    let lo = lo + lo_base;
2434    // SAFETY: the caller's contract.
2435    let (value, mask) = unsafe { force_window(total, wide, lo, width, val, unk) };
2436    let site = (handle.0 as u64, site);
2437    if flags & FORCE_REFRESH == 0 {
2438        note_force(ForceEvent {
2439            cell,
2440            release: false,
2441            procedural: flags & FORCE_PROCEDURAL_ASSIGN != 0,
2442        });
2443    }
2444    let write = crate::state::with_force_table(handle, |t| {
2445        let w = t.force(
2446            cell,
2447            value,
2448            mask,
2449            flags & 1 != 0,
2450            site,
2451            current,
2452            flags & FORCE_PROCEDURAL_ASSIGN != 0,
2453        );
2454        t.set_bypass(true);
2455        w
2456    });
2457    with_current_ops(|ops| ops.commit_blocking_write(cell, write));
2458    crate::state::with_force_table(handle, |t| t.set_bypass(false));
2459}
2460
2461/// `release lhs;`: ends the force of the `width`-bit window at bit `lo` of
2462/// `slot`; a net goes back to its driven value. `flags` is
2463/// [`FORCE_PROCEDURAL_ASSIGN`] for a `deassign`, 0 for a `release`.
2464#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_release"))]
2465pub extern "C" fn real_sv_rt_release(
2466    handle: SimHandle,
2467    slot: vj_abi::SlotId,
2468    lo: u32,
2469    width: u32,
2470    flags: u32,
2471) {
2472    if let Some((row, lo_base)) = crate::state::slot_row_alias(handle, slot) {
2473        release_row_impl(handle, slot, row, lo + lo_base, width, flags);
2474        return;
2475    }
2476    let Some((cell, total, wide, _, lo_base)) = slot_state_or_abort(handle, slot) else {
2477        return;
2478    };
2479    let lo = lo + lo_base;
2480    note_force(ForceEvent {
2481        cell,
2482        release: true,
2483        procedural: flags & FORCE_PROCEDURAL_ASSIGN != 0,
2484    });
2485    let ones = vec![u64::MAX; width.div_ceil(64).max(1) as usize];
2486    // SAFETY: `ones` has the window's word count, used for both lanes.
2487    let (_, mask) = unsafe { force_window(total, wide, lo, width, ones.as_ptr(), ones.as_ptr()) };
2488    let procedural = flags & FORCE_PROCEDURAL_ASSIGN != 0;
2489    if let Some(restore) =
2490        crate::state::with_force_table(handle, |t| t.release(cell, &mask, procedural))
2491    {
2492        // The restored value is not a new driver write: with other windows
2493        // still forced it must not overwrite the entry's `driven` bits with
2494        // their forced values (LRM 10.6.2), so it bypasses the filter.
2495        crate::state::with_force_table(handle, |t| t.set_bypass(true));
2496        with_current_ops(|ops| ops.commit_blocking_write(cell, restore));
2497        crate::state::with_force_table(handle, |t| t.set_bypass(false));
2498    }
2499}
2500
2501/// `force`/`assign` of row `row` of the unpacked array in `slot` (LRM
2502/// 1800-2023 10.6.2): forces the `width`-bit window at bit `lo` of that
2503/// element. [`real_sv_rt_force`] for one memory row; `flags` as there.
2504///
2505/// # Safety
2506/// `val`/`unk` address `width.div_ceil(64)` words each.
2507#[allow(clippy::too_many_arguments)]
2508unsafe fn force_row_impl(
2509    handle: SimHandle,
2510    slot: vj_abi::SlotId,
2511    row: u64,
2512    lo: u32,
2513    width: u32,
2514    val: *const u64,
2515    unk: *const u64,
2516    flags: u32,
2517    site: u64,
2518) {
2519    let (cell, at, total, current) = crate::state::row_state(handle, slot, row);
2520    // SAFETY: the caller's contract; a memory row is always wide.
2521    let (value, mask) = unsafe { force_window(total, true, lo, width, val, unk) };
2522    let procedural = flags & FORCE_PROCEDURAL_ASSIGN != 0;
2523    if flags & FORCE_REFRESH == 0 {
2524        note_force(ForceEvent {
2525            cell,
2526            release: false,
2527            procedural,
2528        });
2529    }
2530    let site = (handle.0 as u64, site);
2531    let write = crate::state::with_force_table(handle, |t| {
2532        let w = t.force_row(
2533            cell,
2534            at,
2535            value,
2536            mask,
2537            flags & 1 != 0,
2538            site,
2539            current,
2540            procedural,
2541        );
2542        t.set_bypass(true);
2543        w
2544    });
2545    commit_row(handle, slot, row, write);
2546    crate::state::with_force_table(handle, |t| t.set_bypass(false));
2547}
2548
2549/// `release`/`deassign` of the `width`-bit window at bit `lo` of row `row`
2550/// of `slot`: [`real_sv_rt_release`] for one memory row.
2551fn release_row_impl(
2552    handle: SimHandle,
2553    slot: vj_abi::SlotId,
2554    row: u64,
2555    lo: u32,
2556    width: u32,
2557    flags: u32,
2558) {
2559    let (cell, at, total, _) = crate::state::row_state(handle, slot, row);
2560    let procedural = flags & FORCE_PROCEDURAL_ASSIGN != 0;
2561    note_force(ForceEvent {
2562        cell,
2563        release: true,
2564        procedural,
2565    });
2566    let ones = vec![u64::MAX; width.div_ceil(64).max(1) as usize];
2567    // SAFETY: `ones` has the window's word count, used for both lanes.
2568    let (_, mask) = unsafe { force_window(total, true, lo, width, ones.as_ptr(), ones.as_ptr()) };
2569    if let Some(restore) =
2570        crate::state::with_force_table(handle, |t| t.release_row(cell, at, &mask, procedural))
2571    {
2572        commit_row(handle, slot, row, restore);
2573    }
2574}
2575
2576/// VPI `vpi_put_value(vpiForceFlag)` on row `row` of the unpacked array in
2577/// `slot` (IEEE 1800-2023 38.34, 10.6.2): [`force_row_impl`], `is_net` the
2578/// net flag.
2579///
2580/// # Safety
2581/// `val`/`unk` address `width.div_ceil(64)` words each.
2582#[allow(clippy::too_many_arguments)]
2583pub unsafe fn vpi_force_row(
2584    handle: SimHandle,
2585    slot: vj_abi::SlotId,
2586    row: u64,
2587    lo: u32,
2588    width: u32,
2589    val: *const u64,
2590    unk: *const u64,
2591    is_net: bool,
2592    site: u64,
2593) {
2594    // SAFETY: the caller's contract.
2595    unsafe {
2596        force_row_impl(
2597            handle,
2598            slot,
2599            row,
2600            lo,
2601            width,
2602            val,
2603            unk,
2604            u32::from(is_net),
2605            site,
2606        );
2607    }
2608}
2609
2610/// VPI `vpi_put_value(vpiReleaseFlag)` on a row: [`release_row_impl`].
2611pub fn vpi_release_row(handle: SimHandle, slot: vj_abi::SlotId, row: u64, lo: u32, width: u32) {
2612    release_row_impl(handle, slot, row, lo, width, 0);
2613}
2614
2615/// A flag bit of `sv_rt_force_row`'s `flags`: the call is a release (a
2616/// deassign with [`FORCE_PROCEDURAL_ASSIGN`]); `val`/`unk` are unused.
2617pub const FORCE_ROW_RELEASE: u32 = 8;
2618
2619/// `force mem[i] = v;` / `release mem[i];` (and `assign`/`deassign`) on row
2620/// `row` of the unpacked array in `slot` (LRM 1800-2023 10.6.2):
2621/// [`real_sv_rt_force`]/[`real_sv_rt_release`] for one memory row, the
2622/// release when `flags` has [`FORCE_ROW_RELEASE`].
2623///
2624/// # Safety
2625/// `val`/`unk` address `width.div_ceil(64)` words each (unused for a
2626/// release).
2627#[allow(clippy::too_many_arguments)]
2628#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_force_row"))]
2629pub unsafe extern "C" fn real_sv_rt_force_row(
2630    handle: SimHandle,
2631    slot: vj_abi::SlotId,
2632    row: u64,
2633    lo: u32,
2634    width: u32,
2635    val: *const u64,
2636    unk: *const u64,
2637    flags: u32,
2638    site: u64,
2639) {
2640    if flags & FORCE_ROW_RELEASE != 0 {
2641        release_row_impl(handle, slot, row, lo, width, flags);
2642    } else {
2643        // SAFETY: the caller's contract.
2644        unsafe { force_row_impl(handle, slot, row, lo, width, val, unk, flags, site) };
2645    }
2646}
2647
2648/// Stores `v` (a wide value) into row `row` of `slot` through the one write
2649/// primitive and wakes what is sensitive to the memory.
2650fn commit_row(handle: SimHandle, slot: vj_abi::SlotId, row: u64, v: FourStateValue) {
2651    let FourStateValue::Wide { mut aval, mut bval } = v else {
2652        return;
2653    };
2654    let view = vj_abi::accessor::WideView {
2655        aval: aval.as_mut_ptr(),
2656        bval: bval.as_mut_ptr(),
2657        word_count: aval.len() as u32,
2658    };
2659    let (handle, slot) = crate::state::resolve_foreign_slot(handle, slot);
2660    let (cell, changed) = crate::state::set_mem_word_reporting_change(handle, slot, row, view);
2661    if changed {
2662        with_current_ops(|ops| ops.notify_blocking_row_change(cell));
2663    }
2664}
2665
2666/// Whether the `force` statement at source offset `site` of `handle`'s
2667/// instance is still in effect: its continuous driver re-forces only while
2668/// it is.
2669#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_force_active"))]
2670pub extern "C" fn real_sv_rt_force_active(handle: SimHandle, site: u64) -> u32 {
2671    u32::from(crate::state::with_force_table(handle, |t| {
2672        t.site_active((handle.0 as u64, site))
2673    }))
2674}
2675
2676/// `$countdrivers` (IEEE 1800-2023 D.2, Table D.1) of bit `bit` of the net in
2677/// `slot`, whose strength key is `key`: writes `out[0..5]` = net_is_forced,
2678/// number_of_01x_drivers, number_of_0_drivers, number_of_1_drivers,
2679/// number_of_x_drivers. A net a resolver drives has its recorded counts; any
2680/// other net has at most one driver, so its value (the one its driver last
2681/// wrote, when forced) says whether that driver is at 0, 1, x or `z`.
2682///
2683/// # Safety
2684///
2685/// `out` points to five writable `u32`.
2686#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_net_drivers"))]
2687pub unsafe extern "C" fn real_sv_rt_net_drivers(
2688    handle: SimHandle,
2689    slot: vj_abi::SlotId,
2690    key: u64,
2691    bit: u32,
2692    out: *mut u32,
2693) {
2694    // Every exit leaves `out` defined: the aborting paths below return with the
2695    // run stopped, but the calling process still reads the five words, and an
2696    // unwritten stack slot made the JIT and AOT builds print different values.
2697    for i in 0..5 {
2698        // SAFETY: the caller's contract.
2699        unsafe { out.add(i).write(0) };
2700    }
2701    let Some((cell, _total, _wide, current, lo_base)) = slot_state_or_abort(handle, slot) else {
2702        return;
2703    };
2704    let (forced, driven) =
2705        crate::state::with_force_table(handle, |t| (t.contains(cell), t.driven_value(cell)));
2706    let counts = crate::state::driver_counts_of(key, bit).unwrap_or_else(|| {
2707        let v = driven.as_ref().unwrap_or(&current);
2708        match crate::state::four_state_bit_at(v, bit + lo_base) {
2709            vj_abi::Bit4::Zero => [1, 0, 0],
2710            vj_abi::Bit4::One => [0, 1, 0],
2711            vj_abi::Bit4::X => [0, 0, 1],
2712            vj_abi::Bit4::Z => [0, 0, 0],
2713        }
2714    });
2715    if counts == crate::state::UNCOUNTED {
2716        eprintln!(
2717            "vj: runtime error: $countdrivers of a net in a bidirectional switch network is not supported"
2718        );
2719        real_sv_rt_finish(handle, 1);
2720        return;
2721    }
2722    let all = [
2723        u32::from(forced),
2724        counts[0] + counts[1] + counts[2],
2725        counts[0],
2726        counts[1],
2727        counts[2],
2728    ];
2729    for (i, v) in all.into_iter().enumerate() {
2730        // SAFETY: the caller's contract.
2731        unsafe { out.add(i).write(v) };
2732    }
2733}
2734
2735/// A `$strobe`/`$monitor` argument thunk codegen emits per call site:
2736/// re-reads the call's arguments from STATE and prints them (LRM 21.2.2/
2737/// 21.2.3 evaluate those arguments at the END of the timestep, not at the
2738/// call).
2739pub type PostponedThunk = extern "C" fn(SimHandle);
2740
2741struct StrobeCallback {
2742    thunk: PostponedThunk,
2743    handle: SimHandle,
2744}
2745
2746// SAFETY: `handle` is the kernel's own instance `SimHandle`, only ever
2747// passed back to the thunk on the scheduler's own thread (same obligation
2748// `CompiledKernelProcess`'s `unsafe impl Send` documents).
2749unsafe impl Send for StrobeCallback {}
2750
2751impl super::RegionCallback for StrobeCallback {
2752    fn invoke(&mut self, _ctx: &dyn super::SchedulerCtx) {
2753        (self.thunk)(self.handle);
2754    }
2755}
2756
2757/// `$strobe(...)`: run `thunk` once, in this timestep's Postponed region.
2758#[cfg_attr(
2759    feature = "aot-exports",
2760    unsafe(export_name = "sv_rt_strobe_postponed")
2761)]
2762pub extern "C" fn real_sv_rt_strobe(handle: SimHandle, thunk: PostponedThunk) {
2763    with_current_ops(|ops| {
2764        ops.register_region_callback(
2765            vj_abi::Region::Postponed,
2766            ops.now(),
2767            Box::new(StrobeCallback { thunk, handle }),
2768        );
2769    });
2770}
2771
2772thread_local! {
2773    /// The active `$monitor`'s generation (0 = none yet). Only one monitor
2774    /// is ever active (LRM 21.2.3): starting a new one bumps this, which
2775    /// retires the old one's callback. Thread-local: one simulation runs on
2776    /// one thread.
2777    static MONITOR_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2778    /// `$monitoron`/`$monitoroff`.
2779    static MONITOR_ENABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
2780}
2781
2782/// High bit of `sv_rt_monitor_start`'s slot count: the slot array carries one
2783/// extra trailing entry, the mask of `$time`-like display values, and the
2784/// monitor prints only when some other value differs from the last print
2785/// (LRM 21.2.3: "each time a variable or an expression in the argument list
2786/// changes value").
2787pub const MONITOR_DEDUPE_N: u32 = 1 << 31;
2788
2789/// What one monitor-thunk call is doing (see [`MonitorDedupe`]).
2790#[derive(Clone, Copy, PartialEq, Eq)]
2791enum MonitorMode {
2792    /// Not inside a thunk call.
2793    Idle,
2794    /// Evaluating the arguments at a watched-cell update: the call compares
2795    /// them with the last evaluated values, sets `due` and prints nothing.
2796    Probe,
2797    /// The Postponed print: records the printed values and prints.
2798    Print,
2799}
2800
2801/// The active plain `$monitor`'s change-test state (LRM 21.2.3: a display is
2802/// due when some argument EXPRESSION took a different value at some update
2803/// during the step, even if a later update restored it): the mask of
2804/// `$time`-like display values, the values (as an opaque key) last evaluated,
2805/// and the per-step "due" flag.
2806struct MonitorDedupe {
2807    mask: u32,
2808    mode: MonitorMode,
2809    last: Option<Vec<u8>>,
2810    /// A display is owed at the end of this step (set by a probe that saw a
2811    /// changed expression, or by `$monitoron`; cleared by the print).
2812    due: bool,
2813    /// The thunk and instance handle a watched-cell update re-evaluates.
2814    probe: Option<(PostponedThunk, SimHandle)>,
2815}
2816
2817thread_local! {
2818    static MONITOR_DEDUPE: std::cell::RefCell<Option<MonitorDedupe>> =
2819        const { std::cell::RefCell::new(None) };
2820}
2821
2822/// A new `$monitor` starts: `Some(mask)` turns the expression-value change
2823/// test on (bit `i` of `mask` exempts display value `i`), `None` off. The
2824/// first display is always due.
2825pub fn monitor_dedupe_reset(mask: Option<u32>) {
2826    MONITOR_DEDUPE.with(|d| {
2827        *d.borrow_mut() = mask.map(|mask| MonitorDedupe {
2828            mask,
2829            mode: MonitorMode::Idle,
2830            last: None,
2831            due: true,
2832            probe: None,
2833        });
2834    });
2835}
2836
2837fn monitor_dedupe_set_mode(mode: MonitorMode) {
2838    MONITOR_DEDUPE.with(|d| {
2839        if let Some(m) = d.borrow_mut().as_mut() {
2840            m.mode = mode;
2841        }
2842    });
2843}
2844
2845/// Arms (or disarms) the print-side change record around one monitor thunk
2846/// call from the Postponed callback.
2847pub fn monitor_dedupe_arm(on: bool) {
2848    monitor_dedupe_set_mode(if on {
2849        MonitorMode::Print
2850    } else {
2851        MonitorMode::Idle
2852    });
2853}
2854
2855/// A watched cell changed value: when the active monitor tests expression
2856/// values, evaluates its arguments now (display suppressed) and marks a
2857/// display due if they differ from the last evaluation.
2858pub fn monitor_probe() {
2859    let probe = MONITOR_DEDUPE.with(|d| {
2860        d.borrow()
2861            .as_ref()
2862            .filter(|m| m.mode == MonitorMode::Idle)
2863            .and_then(|m| m.probe)
2864    });
2865    if let Some((thunk, handle)) = probe {
2866        monitor_dedupe_set_mode(MonitorMode::Probe);
2867        thunk(handle);
2868        monitor_dedupe_set_mode(MonitorMode::Idle);
2869    }
2870}
2871
2872/// `true` while the active `$monitor` tests expression values.
2873fn monitor_dedupe_active() -> bool {
2874    MONITOR_DEDUPE.with(|d| d.borrow().is_some())
2875}
2876
2877/// Reads and clears the active monitor's per-step "display due" flag.
2878fn monitor_dedupe_take_due() -> bool {
2879    MONITOR_DEDUPE.with(|d| {
2880        d.borrow_mut()
2881            .as_mut()
2882            .is_some_and(|m| std::mem::take(&mut m.due))
2883    })
2884}
2885
2886/// Builds this call's value key (`make_key` gets the `$time`-like mask) and
2887/// applies the mode: `Probe` compares it with the last evaluation, remembers
2888/// it, marks the display due on a difference, and returns `true` (suppress the
2889/// print); `Print` remembers it and returns `false`. Always `false` otherwise.
2890pub fn monitor_dedupe_check(make_key: impl FnOnce(u32) -> Vec<u8>) -> bool {
2891    MONITOR_DEDUPE.with(|d| {
2892        let mut d = d.borrow_mut();
2893        let Some(m) = d.as_mut().filter(|m| m.mode != MonitorMode::Idle) else {
2894            return false;
2895        };
2896        let key = make_key(m.mask);
2897        let probing = m.mode == MonitorMode::Probe;
2898        if probing && m.last.as_deref() != Some(key.as_slice()) {
2899            m.due = true;
2900        }
2901        m.last = Some(key);
2902        probing
2903    })
2904}
2905
2906struct MonitorCallback {
2907    thunk: PostponedThunk,
2908    handle: SimHandle,
2909    generation: u64,
2910}
2911
2912// SAFETY: see `StrobeCallback`.
2913unsafe impl Send for MonitorCallback {}
2914
2915impl super::RegionCallback for MonitorCallback {
2916    fn invoke(&mut self, ctx: &dyn super::SchedulerCtx) {
2917        if MONITOR_GENERATION.with(|g| g.get()) != self.generation {
2918            return; // superseded by a later `$monitor`: retire (no re-arm)
2919        }
2920        // Print only if a watched argument signal changed this timestep
2921        // (a change made while `$monitoroff` is held over until re-enabled).
2922        // Under the expression-value change test the probes decided whether
2923        // a display is due; otherwise any watched-signal change is.
2924        if MONITOR_ENABLED.with(|e| e.get()) {
2925            let changed = ctx.take_monitor_changed();
2926            let dedupe = monitor_dedupe_active();
2927            if (dedupe && monitor_dedupe_take_due()) || (!dedupe && changed) {
2928                monitor_dedupe_arm(true);
2929                (self.thunk)(self.handle);
2930                monitor_dedupe_arm(false);
2931            }
2932        }
2933        ctx.rearm(
2934            vj_abi::Region::Postponed,
2935            Box::new(MonitorCallback {
2936                thunk: self.thunk,
2937                handle: self.handle,
2938                generation: self.generation,
2939            }),
2940        );
2941    }
2942}
2943
2944/// `$monitor(...)`: make `thunk` the one active monitor. It is checked in the
2945/// Postponed region of this and every later timestep and prints whenever
2946/// one of its argument signals -- `slots`, this instance's template slot
2947/// numbers, `n` of them -- changed value during the timestep (LRM 21.2.3;
2948/// `$time`-like arguments are not signals, so their change alone never
2949/// triggers a print).
2950///
2951/// # Safety
2952/// `slots` must point at `n` readable `u32`s (codegen passes a constant
2953/// global array) and `handle` must be the calling kernel's instance handle.
2954#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_monitor_start"))]
2955pub unsafe extern "C" fn real_sv_rt_monitor(
2956    handle: SimHandle,
2957    thunk: PostponedThunk,
2958    slots: *const u32,
2959    n: u32,
2960) {
2961    let generation = MONITOR_GENERATION.with(|g| {
2962        let next = g.get() + 1;
2963        g.set(next);
2964        next
2965    });
2966    // High bit of `n`: one trailing entry holds the `$time`-like mask and
2967    // the monitor prints only when an argument expression's value changed.
2968    let dedupe = n & MONITOR_DEDUPE_N != 0;
2969    let n = n & !MONITOR_DEDUPE_N;
2970    let all: &[u32] = if n == 0 && !dedupe {
2971        &[]
2972    } else {
2973        // SAFETY: forwarded to this function's own `# Safety` section.
2974        unsafe { std::slice::from_raw_parts(slots, n as usize + usize::from(dedupe)) }
2975    };
2976    let (slots, mask) = if dedupe {
2977        (&all[..n as usize], Some(all[n as usize]))
2978    } else {
2979        (all, None)
2980    };
2981    monitor_dedupe_reset(mask);
2982    if mask.is_some() {
2983        MONITOR_DEDUPE.with(|d| {
2984            if let Some(m) = d.borrow_mut().as_mut() {
2985                m.probe = Some((thunk, handle));
2986            }
2987        });
2988    }
2989    let cells: Vec<StateCellId> = slots
2990        .iter()
2991        .map(|&s| crate::state::cell_for_slot(handle, vj_abi::SlotId(s)))
2992        .collect();
2993    with_current_ops(|ops| {
2994        ops.set_monitor_watch(cells);
2995        ops.register_region_callback(
2996            vj_abi::Region::Postponed,
2997            ops.now(),
2998            Box::new(MonitorCallback {
2999                thunk,
3000                handle,
3001                generation,
3002            }),
3003        );
3004    });
3005}
3006
3007/// `$monitoron` / `$monitoroff` (LRM 21.2.3).
3008#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_monitor_enable"))]
3009pub extern "C" fn real_sv_rt_monitor_enable(_handle: SimHandle, on: i32) {
3010    MONITOR_ENABLED.with(|e| e.set(on != 0));
3011    // LRM 21.2.3: `$monitoron` produces a display whether or not a value
3012    // changed (under the expression-value change test; the per-signal form
3013    // prints only held-over changes).
3014    if on != 0 {
3015        MONITOR_DEDUPE.with(|d| {
3016            if let Some(m) = d.borrow_mut().as_mut() {
3017                m.due = true;
3018            }
3019        });
3020    }
3021}
3022
3023thread_local! {
3024    /// The time step a concurrent assertion's leaf is being evaluated for, while the
3025    /// engine re-runs an attempt over its window (`sva::engine`): `$time`/`$realtime`
3026    /// in an assertion expression is the time of the position the leaf is sampled at
3027    /// (LRM 1800-2023 16.5.1), not that of the later step doing the re-run.
3028    static SVA_LEAF_TIME: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
3029}
3030
3031/// Runs `f` with `$time` answering `ticks` ([`SVA_LEAF_TIME`]).
3032pub fn with_sva_leaf_time<R>(ticks: u64, f: impl FnOnce() -> R) -> R {
3033    let prev = SVA_LEAF_TIME.replace(Some(ticks));
3034    let r = f();
3035    SVA_LEAF_TIME.set(prev);
3036    r
3037}
3038
3039/// Task F2 (`docs/superpowers/plans/2026-09-27-wave-A2-slice-finish.md`,
3040/// RT-043 partial): the real production body for the frozen `sv_rt_time`
3041/// ABI symbol (`vj_codegen::expr::SV_RT_TIME_SYMBOL`) that `$time`/
3042/// `$stime`/`$realtime` lower to -- reaches the scheduler's real
3043/// [`SchedulerOps::now`] through the same [`CURRENT_OPS`] thread-local
3044/// bridge [`real_nba_enqueue`]/[`real_sv_rt_finish`] above already use.
3045/// Bound the same way as those two (`Jit::add_global_mapping(time_fv,
3046/// real_sv_rt_time as *const () as usize)`, `vj::run`).
3047///
3048/// Takes no `SimHandle` parameter at all -- a deviation both from those two
3049/// functions' own shape and from the plan's literal `sv_rt_time(state:
3050/// SimHandle) -> u64` sketch, and load-bearing, not cosmetic:
3051/// `vj-codegen::expr::fetch_or_declare_sv_rt_time` declares the LLVM
3052/// side of this ABI boundary as a genuinely zero-argument function (that doc
3053/// comment records why codegen's call site has no `SimHandle`-carrying value
3054/// in scope to pass), so this Rust function's own signature must match it
3055/// exactly -- an extra Rust-side parameter here would desync the `extern
3056/// "C"` calling convention from the zero-argument call site `build_call`
3057/// emits (nothing would ever populate it, unlike [`real_sv_rt_finish`],
3058/// whose `_handle` parameter IS genuinely passed a real argument by its own
3059/// two-argument call site, just never read). The current time is
3060/// scheduler-global, not per-instance, so no `SimHandle` is needed here
3061/// either way.
3062///
3063/// A miss (`with_current_ops` finds no `CURRENT_OPS` set) falls through to
3064/// `0` -- unreachable for every kernel this tree's own codegen emits, per
3065/// [`with_current_ops`]'s own doc comment, not a silently-wrong nonzero
3066/// default masking a real bug.
3067#[cfg_attr(feature = "aot-exports", unsafe(export_name = "sv_rt_time"))]
3068pub extern "C" fn real_sv_rt_time() -> u64 {
3069    if let Some(t) = SVA_LEAF_TIME.get() {
3070        return t;
3071    }
3072    let mut ticks: u64 = 0;
3073    with_current_ops(|ops| ticks = ops.now().ticks);
3074    ticks
3075}
3076
3077/// The reconciliation note this module's own doc comment describes --
3078/// `sv_kernel_abi_reconciliation_note_exists_and_is_referenced` (below) is
3079/// the cheap compile-level proof it is not silently dropped in a later
3080/// refactor.
3081pub const SV_KERNEL_ABI_RECONCILIATION: &str = "CG-084/CG-111..CG-116 (docs/design/codegen.md §9's SvKernelTable/SvProcessDesc/SvWakeSpec/\
3082     SvResumeStatus literal draft) is superseded by ARCHITECTURE.md §5's frozen ABI \
3083     (SimHandle/ProcessFn/EvalResult/Checkpoint), the same reconciliation PR-037/PR-107 already \
3084     record for the process-model side. CompiledKernelProcess (vj-sim::sched::kernel_adapter) \
3085     and lower_kernel_fn (vj-codegen::process_lower) implement the frozen shape; the old \
3086     struct names are never constructed anywhere in this tree.";
3087
3088#[cfg(test)]
3089mod tests {
3090    use super::*;
3091    use crate::sched::Region;
3092
3093    /// Minimal `SchedulerOps` double: `CompiledKernelProcess::resume` never
3094    /// CALLS A METHOD on `ops` itself (it only calls the raw kernel fn
3095    /// pointer, and, since Task S7, stores `ops`'s address in
3096    /// `CURRENT_OPS` for that call's duration -- see `with_scheduler_ops`),
3097    /// so every method here is unreachable in these tests -- kept total (no
3098    /// `unimplemented!`) since a future `resume` revision that starts using
3099    /// `ops` should get a real (if trivial) answer, not a panic.
3100    struct NoOpSchedulerOps;
3101    impl SchedulerOps for NoOpSchedulerOps {
3102        fn now(&self) -> crate::sched::SchedTime {
3103            crate::sched::SchedTime { ticks: 0 }
3104        }
3105        fn current_region(&self) -> Region {
3106            Region::Active
3107        }
3108        fn stage_nba(
3109            &self,
3110            _cell: StateCellId,
3111            _value: crate::state::FourStateValue,
3112            _source: u32,
3113        ) {
3114        }
3115        fn commit_blocking_write(&self, _cell: StateCellId, _value: crate::state::FourStateValue) {}
3116        fn request_finish(&self, _code: i32) {}
3117        fn defer_io(
3118            &self,
3119            _at: crate::sched::SchedTime,
3120            _region: Region,
3121            _cb: crate::sched::DeferredIoCallback,
3122        ) {
3123        }
3124        fn register_region_callback(
3125            &self,
3126            _region: Region,
3127            _at: crate::sched::SchedTime,
3128            _cb: Box<dyn crate::sched::RegionCallback>,
3129        ) {
3130        }
3131    }
3132
3133    /// PR-012: `resume_pc == 0` means "fresh/never-run" -- a hand-written
3134    /// stub kernel asserts it was actually called with `0` on the first call,
3135    /// and reports a delay suspend the adapter must decode+persist correctly.
3136    extern "C" fn stub_kernel_initial_dispatch(
3137        _h: SimHandle,
3138        _local: *mut u8,
3139        resume_pc: u32,
3140    ) -> EvalResult {
3141        assert_eq!(
3142            resume_pc, 0,
3143            "PR-012: the first call to a freshly constructed CompiledKernelProcess must pass \
3144             resume_pc == 0"
3145        );
3146        build_eval_result(ResolvedSuspend::Delay(5), 1)
3147    }
3148
3149    #[test]
3150    fn resume_at_pc_zero_is_initial_dispatch() {
3151        let mut proc = CompiledKernelProcess::new(
3152            stub_kernel_initial_dispatch,
3153            0,
3154            SimHandle(std::ptr::null_mut()),
3155        );
3156        assert_eq!(proc.resume_pc, 0);
3157        let ops = NoOpSchedulerOps;
3158        let suspend = proc.resume(SimHandle(std::ptr::null_mut()), ResumeArg::Start, &ops);
3159        match suspend {
3160            Suspend::Delay(t) => assert_eq!(t, 5),
3161            other => panic!("expected Suspend::Delay(5), got {other:?}"),
3162        }
3163        assert_eq!(
3164            proc.resume_pc, 1,
3165            "the adapter must persist the decoded next resume_pc for the following call"
3166        );
3167    }
3168
3169    #[test]
3170    fn resume_pc_round_trips_through_evalresult_and_reschedulereq() {
3171        let er_edge = build_eval_result(
3172            ResolvedSuspend::Edge {
3173                cell: StateCellId(7),
3174                edge: EdgeKind::Posedge,
3175            },
3176            3,
3177        );
3178        match decode_reschedule(er_edge).expect("KIND_EDGE must decode to Ok") {
3179            Suspend::WaitEvents(set) => {
3180                assert_eq!(set.terms.len(), 1);
3181                match &set.terms[0] {
3182                    EventTerm::Edge { cell, edge, iff } => {
3183                        assert_eq!(*cell, StateCellId(7));
3184                        assert_eq!(*edge, EdgeKind::Posedge);
3185                        assert!(iff.is_none());
3186                    }
3187                    other => panic!("expected EventTerm::Edge, got {other:?}"),
3188                }
3189            }
3190            other => panic!("expected Suspend::WaitEvents, got {other:?}"),
3191        }
3192
3193        let er_delay = build_eval_result(ResolvedSuspend::Delay(10), 2);
3194        match decode_reschedule(er_delay).expect("KIND_DELAY must decode to Ok") {
3195            Suspend::Delay(t) => assert_eq!(t, 10),
3196            other => panic!("expected Suspend::Delay(10), got {other:?}"),
3197        }
3198
3199        let er_done = build_eval_result(ResolvedSuspend::Done, 0);
3200        match decode_reschedule(er_done).expect("KIND_DONE must decode to Ok") {
3201            Suspend::Done => {}
3202            other => panic!("expected Suspend::Done, got {other:?}"),
3203        }
3204    }
3205
3206    /// Classes plan Task 13: `ResolvedSuspend::ByRequest` (the wire form of
3207    /// `p.await()` / `p.suspend()`) round-trips to `Suspend::ByRequest` on
3208    /// its own wire kind -- never aliased to `Done`/`Delay`, which would
3209    /// terminate or re-time the process instead of resuming it.
3210    #[test]
3211    fn by_request_round_trips_through_reschedule_req() {
3212        let er = build_eval_result(ResolvedSuspend::ByRequest, 9);
3213        match decode_reschedule(er).expect("KIND_BY_REQUEST must decode to Ok") {
3214            Suspend::ByRequest => {}
3215            other => panic!("expected Suspend::ByRequest, got {other:?}"),
3216        }
3217    }
3218
3219    /// Task 6 (CG-081/CG-171): `build_eval_result`/`decode_reschedule` round
3220    /// trip for `ResolvedSuspend::RepeatEdge` -- the wire tag `SuspendPoint::
3221    /// CycleDelay` resolves into. Asserts the decoded `Suspend::WaitEvents`
3222    /// carries exactly one `EventTerm::Repeat{count, inner: Edge{cell,
3223    /// edge}}` term -- the raw, un-multiplied cycle count, never a
3224    /// pre-resolved tick offset (ARCHITECTURE §6.5's own "converted at
3225    /// runtime" requirement, proven bit-for-bit at this wire layer).
3226    #[test]
3227    fn repeat_edge_round_trips_through_reschedule_req_as_a_live_repeat_over_edge_term() {
3228        let er = build_eval_result(
3229            ResolvedSuspend::RepeatEdge {
3230                count: 3,
3231                cell: StateCellId(11),
3232                edge: EdgeKind::Posedge,
3233            },
3234            4,
3235        );
3236        match decode_reschedule(er).expect("KIND_REPEAT_EDGE must decode to Ok") {
3237            Suspend::WaitEvents(set) => {
3238                assert_eq!(set.terms.len(), 1);
3239                match &set.terms[0] {
3240                    EventTerm::Repeat { count, inner } => {
3241                        assert_eq!(*count, 3);
3242                        match inner.as_ref() {
3243                            EventTerm::Edge { cell, edge, iff } => {
3244                                assert_eq!(*cell, StateCellId(11));
3245                                assert_eq!(*edge, EdgeKind::Posedge);
3246                                assert!(iff.is_none());
3247                            }
3248                            other => panic!("expected EventTerm::Edge, got {other:?}"),
3249                        }
3250                    }
3251                    other => panic!("expected EventTerm::Repeat, got {other:?}"),
3252                }
3253            }
3254            other => panic!("expected Suspend::WaitEvents, got {other:?}"),
3255        }
3256    }
3257
3258    /// Task 6: the repeat count's full `u64` range survives the wire's `a`/
3259    /// `b` split -- mirrors `KIND_DELAY`'s own near-`u64::MAX` proof.
3260    #[test]
3261    fn repeat_edge_count_round_trips_near_u64_max_through_the_wire() {
3262        let near_max: u64 = u64::MAX - 1;
3263        let er = build_eval_result(
3264            ResolvedSuspend::RepeatEdge {
3265                count: near_max,
3266                cell: StateCellId(1),
3267                edge: EdgeKind::Negedge,
3268            },
3269            0,
3270        );
3271        match decode_reschedule(er).expect("KIND_REPEAT_EDGE must decode to Ok") {
3272            Suspend::WaitEvents(set) => match &set.terms[0] {
3273                EventTerm::Repeat { count, .. } => assert_eq!(*count, near_max),
3274                other => panic!("expected EventTerm::Repeat, got {other:?}"),
3275            },
3276            other => panic!("expected Suspend::WaitEvents, got {other:?}"),
3277        }
3278    }
3279
3280    /// **Review fix (wB-sched Task 5 r1) -- the reachability proof behind
3281    /// `docs/CONTRACTS.md`'s "Partial closures (wave B)" / SC-068 entry.**
3282    /// [`ResolvedSuspend::Edge`] (this module's own boundary type, and the
3283    /// *only* shape `vj-codegen::process_lower::resolve_suspend_point`
3284    /// -- the one real, non-test producer of a suspend request for a
3285    /// compiled kernel -- can construct for an edge wait) has no `iff` field
3286    /// at all, so no real compiled kernel can ever cross this ABI boundary
3287    /// with one: [`build_eval_result`]/[`decode_reschedule`]'s round trip
3288    /// always yields `EventTerm::Edge { iff: None, .. }`, regardless of
3289    /// whether the original SystemVerilog wait was `iff`-qualified
3290    /// (`@(posedge clk iff en)`). This is why `EngineNotifySink::
3291    /// notify_change`'s `Some(pred)` filter branch (`sched/control.rs`,
3292    /// SC-068) is exercised, today, only by a test that hand-builds an
3293    /// `EventTerm::Edge { iff: Some(_), .. }` directly (e.g. `sched::
3294    /// control::tests::engine_never_calls_resume_when_iff_is_false_but_does_
3295    /// on_the_next_true_edge`) -- never by anything this crate's own ABI can
3296    /// produce from a real kernel. `iff` correctness for a real compiled
3297    /// kernel instead comes entirely from the separate resume-prologue
3298    /// mechanism in `vj-codegen::process_lower` (`compile_iff_guard`,
3299    /// settled by `docs/design/process.md`'s "iff re-check" section) -- the
3300    /// two mechanisms are not reconciled. If a future change threads `iff`
3301    /// across this ABI boundary (`RescheduleReqLayoutProposal` gaining a
3302    /// field, `resolve_suspend_point` populating it), this assertion flips
3303    /// to `Some` -- that is the documented flip condition; update this test
3304    /// and the CONTRACTS.md/ledger entries together rather than deleting it.
3305    ///
3306    /// **Task 7 (AB-096) status -- this test is still exactly correct,
3307    /// unchanged:** the ABI-boundary wire representation half of the flip
3308    /// condition now exists (`RescheduleReqLayoutProposal::guard_fn`/
3309    /// `ResolvedSuspend::EdgeWithGuard`, proved by
3310    /// `guard_fn_round_trips_through_reschedule_req` below) and IS reachable
3311    /// via `decode_reschedule` -- but only for that NEW variant, never for a
3312    /// plain `ResolvedSuspend::Edge` (still the ONLY shape
3313    /// `resolve_suspend_point` itself ever constructs today). See
3314    /// `docs/CONTRACTS.md`'s SC-068 entry, "R-run Task 7 addendum," for the
3315    /// full accounting of what still blocks `resolve_suspend_point` from
3316    /// constructing `EdgeWithGuard` for a real compiled kernel.
3317    #[test]
3318    fn sc068_decode_reschedule_can_never_produce_a_some_iff_edge_term() {
3319        let er = build_eval_result(
3320            ResolvedSuspend::Edge {
3321                cell: StateCellId(9),
3322                edge: EdgeKind::Posedge,
3323            },
3324            0,
3325        );
3326        match decode_reschedule(er).expect("KIND_EDGE must decode to Ok") {
3327            Suspend::WaitEvents(set) => {
3328                assert_eq!(set.terms.len(), 1);
3329                match &set.terms[0] {
3330                    EventTerm::Edge { iff, .. } => assert!(
3331                        iff.is_none(),
3332                        "SC-068 reachability gap regressed: decode_reschedule must never invent \
3333                         an iff predicate -- ResolvedSuspend::Edge (the only shape a real \
3334                         compiled kernel's resolve_suspend_point can produce) has no iff field \
3335                         to carry one across this ABI boundary today"
3336                    ),
3337                    other => panic!("expected EventTerm::Edge, got {other:?}"),
3338                }
3339            }
3340            other => panic!("expected Suspend::WaitEvents, got {other:?}"),
3341        }
3342    }
3343
3344    /// A real, top-level `extern "C" fn` for the guard round-trip test below
3345    /// -- never a closure (no stable, ABI-crossable function pointer of the
3346    /// `extern "C" fn(SimHandle) -> bool` shape).
3347    extern "C" fn stub_guard_fn(_h: SimHandle) -> bool {
3348        false
3349    }
3350
3351    /// Task 7 (AB-096): a real `extern "C" fn` pointer round-trips
3352    /// byte-identical through [`build_eval_result`]/[`decode_reschedule`] --
3353    /// the literal "documented flip condition"
3354    /// `sc068_decode_reschedule_can_never_produce_a_some_iff_edge_term`'s
3355    /// own doc comment names: this is the future change that flips that
3356    /// assertion to `Some`, for the `ResolvedSuspend::EdgeWithGuard` shape
3357    /// specifically (a plain `ResolvedSuspend::Edge` still decodes `iff:
3358    /// None`, proven by that same still-passing, unmodified test).
3359    #[test]
3360    fn guard_fn_round_trips_through_reschedule_req() {
3361        let er = build_eval_result(
3362            ResolvedSuspend::EdgeWithGuard {
3363                cell: StateCellId(11),
3364                edge: EdgeKind::Negedge,
3365                guard_fn: stub_guard_fn,
3366            },
3367            3,
3368        );
3369        match decode_reschedule(er).expect("KIND_EDGE (with guard) must decode to Ok") {
3370            Suspend::WaitEvents(set) => {
3371                assert_eq!(set.terms.len(), 1);
3372                match &set.terms[0] {
3373                    EventTerm::Edge { cell, edge, iff } => {
3374                        assert_eq!(*cell, StateCellId(11));
3375                        assert_eq!(*edge, EdgeKind::Negedge);
3376                        let PredicateFn(decoded) =
3377                            iff.expect("an EdgeWithGuard suspend must decode iff: Some(..)");
3378                        assert_eq!(
3379                            decoded as *const () as usize, stub_guard_fn as *const () as usize,
3380                            "the decoded guard function pointer must be byte-identical to the \
3381                             original"
3382                        );
3383                    }
3384                    other => panic!("expected EventTerm::Edge, got {other:?}"),
3385                }
3386            }
3387            other => panic!("expected Suspend::WaitEvents, got {other:?}"),
3388        }
3389    }
3390
3391    /// Review fix (A2-part2 r2, binding rule 8): `decode_reschedule` must
3392    /// return a typed [`DecodeRescheduleError`], never panic, for
3393    /// `KIND_EDGE_LIST`/`KIND_FORK_JOIN` -- constructed here directly via
3394    /// `RescheduleReqLayoutProposal::from_edge_list`/`from_fork_join` (never
3395    /// through `build_eval_result`, which -- by design, see
3396    /// `DecodeRescheduleError`'s own doc comment -- never produces these
3397    /// kinds) and packed into the wire form the same way
3398    /// `build_eval_result` packs every other kind, so this test exercises
3399    /// the real decode path, not a hand-rolled shortcut.
3400    #[test]
3401    fn decode_reschedule_reports_typed_errors_for_edge_list_and_fork_join() {
3402        use crate::sched::JoinKind;
3403
3404        fn wrap(proposal: RescheduleReqLayoutProposal) -> EvalResult {
3405            let mut opaque = [0u8; vj_abi::eval::RESCHEDULE_REQ_SIZE];
3406            opaque[..PROPOSAL_SIZE].copy_from_slice(&proposal.to_bytes());
3407            EvalResult {
3408                outcome: EvalOutcome::SuspendedOnEvent,
3409                reschedule: RescheduleReq { opaque },
3410            }
3411        }
3412
3413        let edge_list = wrap(RescheduleReqLayoutProposal::from_edge_list(ArenaRange {
3414            base: 3,
3415            count: 5,
3416        }));
3417        match decode_reschedule(edge_list) {
3418            Err(DecodeRescheduleError::UnresolvedEdgeList { base, count }) => {
3419                assert_eq!(base, 3);
3420                assert_eq!(count, 5);
3421            }
3422            other => panic!(
3423                "KIND_EDGE_LIST must decode to Err(UnresolvedEdgeList{{3,5}}), never panic; got \
3424                 {other:?}"
3425            ),
3426        }
3427
3428        let fork_join = wrap(RescheduleReqLayoutProposal::from_fork_join(
3429            JoinKind::All,
3430            ArenaRange { base: 10, count: 2 },
3431        ));
3432        match decode_reschedule(fork_join) {
3433            Err(DecodeRescheduleError::UnresolvedForkJoin {
3434                join_kind,
3435                base,
3436                count,
3437            }) => {
3438                assert_eq!(join_kind, JoinKind::All as u8);
3439                assert_eq!(base, 10);
3440                assert_eq!(count, 2);
3441            }
3442            other => panic!(
3443                "KIND_FORK_JOIN must decode to Err(UnresolvedForkJoin{{..}}), never panic; got \
3444                 {other:?}"
3445            ),
3446        }
3447    }
3448
3449    /// The sibling case for a byte outside `KIND_DELAY..=KIND_DONE` entirely
3450    /// (an ABI/version mismatch) -- also a typed `Err`, never a panic.
3451    #[test]
3452    fn decode_reschedule_reports_a_typed_error_for_an_out_of_range_kind_byte() {
3453        let mut opaque = [0u8; vj_abi::eval::RESCHEDULE_REQ_SIZE];
3454        opaque[0] = 200;
3455        let er = EvalResult {
3456            outcome: EvalOutcome::Completed,
3457            reschedule: RescheduleReq { opaque },
3458        };
3459        match decode_reschedule(er) {
3460            Err(DecodeRescheduleError::InvalidKind(200)) => {}
3461            other => panic!("expected Err(InvalidKind(200)), never a panic; got {other:?}"),
3462        }
3463    }
3464
3465    #[test]
3466    fn sv_kernel_abi_reconciliation_note_exists_and_is_referenced() {
3467        assert!(
3468            SV_KERNEL_ABI_RECONCILIATION.contains("CG-084"),
3469            "the reconciliation note must cite CG-084 so a reader auditing that ledger row \
3470             finds this exact note"
3471        );
3472    }
3473
3474    /// PR-101/PR-102's structural proof: exactly one `impl CompiledProcess
3475    /// for` anywhere in this crate's PRODUCTION code (never inside a
3476    /// `#[cfg(test)]` body, which could trivially inflate the count with a
3477    /// throwaway test double) bridges a *kernel-backed* type. Mirrors
3478    /// `sched::process_table`'s own `scan_is_genuinely_slotmap_backed`
3479    /// pattern: strip every `#[cfg(test)]` block first via the shared
3480    /// `determinism::scan_support` helper, then search what remains.
3481    ///
3482    /// **Documented carve-out:** `sched::control::UnmintedForkChild`
3483    /// (pre-existing, committed before this task) is also a production
3484    /// `impl CompiledProcess for` -- but it is a fork/join placeholder
3485    /// ("a fork child whose real compiled body codegen has not minted yet",
3486    /// out of the A2 slice's scope entirely, PR-086) that resumes to
3487    /// `Suspend::Done` unconditionally and bridges no kernel/ABI boundary at
3488    /// all. It is excluded by name here rather than silently miscounted as a
3489    /// second kernel adapter -- the two are not the same claim, and pretending
3490    /// they were would either make this test permanently fail for a reason
3491    /// unrelated to PR-101/PR-102, or force deleting an unrelated, correct
3492    /// placeholder this task does not own.
3493    #[test]
3494    fn exactly_one_compiledprocess_impl_over_a_kernel_backed_type() {
3495        const UNRELATED_PLACEHOLDER: &str = "impl CompiledProcess for UnmintedForkChild";
3496
3497        let src_root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src"));
3498        let mut total = 0usize;
3499        let mut stack = vec![src_root.to_path_buf()];
3500        while let Some(dir) = stack.pop() {
3501            for entry in std::fs::read_dir(&dir).expect("read_dir must succeed under src/") {
3502                let entry = entry.expect("dir entry must be readable");
3503                let path = entry.path();
3504                if path.is_dir() {
3505                    stack.push(path);
3506                    continue;
3507                }
3508                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
3509                    continue;
3510                }
3511                let text = std::fs::read_to_string(&path)
3512                    .unwrap_or_else(|e| panic!("must be able to read {path:?}: {e}"));
3513                let production_only =
3514                    crate::determinism::scan_support::strip_cfg_test_blocks(&text);
3515                let here = production_only.matches("impl CompiledProcess for").count()
3516                    - production_only.matches(UNRELATED_PLACEHOLDER).count();
3517                total += here;
3518            }
3519        }
3520        assert_eq!(
3521            total, 1,
3522            "expected exactly one kernel-backed production `impl CompiledProcess for` in the \
3523             whole crate (this module's own, on CompiledKernelProcess) -- found {total}"
3524        );
3525    }
3526
3527    /// Task S7: a `SchedulerOps` double that records every `request_finish`
3528    /// call it receives.
3529    struct RecordingSchedulerOps {
3530        finish_calls: std::cell::RefCell<Vec<i32>>,
3531    }
3532    impl SchedulerOps for RecordingSchedulerOps {
3533        fn now(&self) -> crate::sched::SchedTime {
3534            crate::sched::SchedTime { ticks: 0 }
3535        }
3536        fn current_region(&self) -> Region {
3537            Region::Active
3538        }
3539        fn stage_nba(
3540            &self,
3541            _cell: StateCellId,
3542            _value: crate::state::FourStateValue,
3543            _source: u32,
3544        ) {
3545        }
3546        fn commit_blocking_write(&self, _cell: StateCellId, _value: crate::state::FourStateValue) {}
3547        fn request_finish(&self, code: i32) {
3548            self.finish_calls.borrow_mut().push(code);
3549        }
3550        fn defer_io(
3551            &self,
3552            _at: crate::sched::SchedTime,
3553            _region: Region,
3554            _cb: crate::sched::DeferredIoCallback,
3555        ) {
3556        }
3557        fn register_region_callback(
3558            &self,
3559            _region: Region,
3560            _at: crate::sched::SchedTime,
3561            _cb: Box<dyn crate::sched::RegionCallback>,
3562        ) {
3563        }
3564    }
3565
3566    /// Task S7 (the `SimHandle`-reachability bridge): `with_scheduler_ops`
3567    /// makes `ops` reachable to `real_sv_rt_finish` for exactly the duration
3568    /// of the wrapped call -- proving the actual mechanism `CompiledKernelProcess::
3569    /// resume` now uses to make `nba_enqueue`/`sv_rt_finish`'s real bodies
3570    /// reach the scheduler, not merely that `request_finish` itself works.
3571    #[test]
3572    fn with_scheduler_ops_makes_ops_reachable_to_real_sv_rt_finish() {
3573        let ops = RecordingSchedulerOps {
3574            finish_calls: std::cell::RefCell::new(Vec::new()),
3575        };
3576        let handle = SimHandle(std::ptr::null_mut());
3577        with_scheduler_ops(&ops, || {
3578            real_sv_rt_finish(handle, 7);
3579        });
3580        assert_eq!(
3581            *ops.finish_calls.borrow(),
3582            vec![7],
3583            "real_sv_rt_finish must reach the real ops.request_finish while inside \
3584             with_scheduler_ops's callback"
3585        );
3586    }
3587
3588    /// Outside any `with_scheduler_ops` call, `real_sv_rt_finish`/
3589    /// `with_current_ops` must be a silent no-op, never a panic -- proven by
3590    /// simply calling it with nothing installed and observing no crash (see
3591    /// `with_current_ops`'s own doc comment for why this shape never arises
3592    /// from a real kernel).
3593    #[test]
3594    fn real_sv_rt_finish_outside_with_scheduler_ops_does_not_panic() {
3595        let handle = SimHandle(std::ptr::null_mut());
3596        real_sv_rt_finish(handle, 1);
3597    }
3598
3599    /// Review fix (A2-part2 r1, binding rule 7): `with_current_process`
3600    /// makes `proc` reachable through `CURRENT_PROCESS` for exactly the
3601    /// duration of the wrapped call, and restores the prior (here: `None`)
3602    /// value afterward -- the same "installed for one call, then restored"
3603    /// contract `with_scheduler_ops`/`CURRENT_OPS` already give `ops`,
3604    /// proven the identical way (`with_scheduler_ops_makes_ops_reachable_to_
3605    /// real_sv_rt_finish` above).
3606    #[test]
3607    fn with_current_process_makes_proc_reachable_for_exactly_one_call_and_restores_after() {
3608        assert_eq!(
3609            CURRENT_PROCESS.with(|c| *c.borrow()),
3610            None,
3611            "precondition: no other test on this thread left CURRENT_PROCESS set"
3612        );
3613        let seen_inside = with_current_process(42, || CURRENT_PROCESS.with(|c| *c.borrow()));
3614        assert_eq!(
3615            seen_inside,
3616            Some(42),
3617            "CURRENT_PROCESS must read back the ProcessId with_current_process installed, \
3618             from inside the wrapped call"
3619        );
3620        assert_eq!(
3621            CURRENT_PROCESS.with(|c| *c.borrow()),
3622            None,
3623            "with_current_process must restore the prior (None) value after the wrapped call \
3624             returns, not leave 42 installed"
3625        );
3626    }
3627
3628    /// The save/restore half of the same contract, under nesting: returning
3629    /// from an inner `with_current_process` must restore the OUTER caller's
3630    /// `ProcessId`, never merely clear it to `None` -- the exact property
3631    /// `CURRENT_OPS`'s own doc comment cites as the reason it saves-and-
3632    /// restores instead of clearing (a hypothetical nested compiled-kernel
3633    /// call, e.g. a future fork/join child resumed from inside a parent's
3634    /// own `resume()`, must see ITS OWN `ProcessId` while running, and the
3635    /// parent's `ProcessId` must still be correct once control returns to
3636    /// it).
3637    #[test]
3638    fn with_current_process_restores_the_outer_value_not_merely_clears_it() {
3639        with_current_process(1, || {
3640            let inner = with_current_process(2, || CURRENT_PROCESS.with(|c| *c.borrow()));
3641            assert_eq!(inner, Some(2));
3642            assert_eq!(
3643                CURRENT_PROCESS.with(|c| *c.borrow()),
3644                Some(1),
3645                "returning from a nested with_current_process must restore the OUTER caller's \
3646                 ProcessId, not clear it to None"
3647            );
3648        });
3649        assert_eq!(
3650            CURRENT_PROCESS.with(|c| *c.borrow()),
3651            None,
3652            "after the outermost with_current_process call returns, CURRENT_PROCESS must be \
3653             back to None"
3654        );
3655    }
3656
3657    /// Review fix (A2-part2 r2, finding 3): `two_process_nba_same_var_
3658    /// matches_iverilog_bit_for_bit` (`vj-harness`) proves `commit_nba`'s
3659    /// own push-order hazard resolution (a correct, independent SC-039
3660    /// design -- see `sched::notify::commit_nba`'s doc comment: it never
3661    /// reads `NbaUpdate.source`/`entry.source` at all, tie-breaking purely
3662    /// by push/insertion order). That test's own fixture is a causal
3663    /// (insertion-order) dependency between its two processes, so it passes
3664    /// or fails on push-order correctness alone and cannot tell "CURRENT_
3665    /// PROCESS wired correctly" apart from "CURRENT_PROCESS absent/always-0"
3666    /// -- reverting the whole `CURRENT_PROCESS` bridge would not change that
3667    /// test's outcome. This test exercises the one thing r1's fix actually
3668    /// changed instead: `real_nba_enqueue`'s own `source` output, directly,
3669    /// with and without `with_current_process` installed -- proving the
3670    /// bridge is genuinely live, not merely present-but-unread.
3671    #[test]
3672    fn real_nba_enqueue_prefers_current_process_over_the_raw_abi_source() {
3673        // Minimal, frontend-free single-scalar-slot template/instance --
3674        // `state::instance`'s own doc comments explain why `TemplateRegistry`/
3675        // `StateLayout`/`SlotDescriptor` need no `sv_lang` dependency at all,
3676        // so this test builds one without a real `sv_lang::Design`.
3677        let template = crate::netlist::TemplateKey {
3678            definition: 1,
3679            params_hash: 0,
3680        };
3681        let layout = crate::state::StateLayout {
3682            slots: vec![crate::state::SlotDescriptor {
3683                key: crate::netlist::SymbolKey(0),
3684                offset: crate::state::SlotOffset {
3685                    class: crate::state::SlotClass::Inline,
3686                    word_offset: 0,
3687                    bit_width: 1,
3688                    elem_word_count: 1,
3689                    row_stride_words: 0,
3690                    num_rows: 0,
3691                },
3692                initial: None,
3693                net_info: None,
3694            }],
3695            inline_words: 1,
3696            wide_words: 0,
3697        };
3698        let registry = crate::state::TemplateRegistry::new();
3699        // SAFETY: single-threaded test; no other live `&StateLayout` borrow
3700        // and no concurrent `register` call are in flight -- exactly
3701        // `TemplateRegistry::register`'s own documented obligation.
3702        unsafe {
3703            registry.register(template, layout);
3704        }
3705        let mut engine = crate::state::StateEngine::new(registry);
3706        let handle = engine
3707            .allocate_instance(template)
3708            .expect("template was just registered above");
3709        let cell = handle.cell_for_slot(0);
3710        let h = handle.into_sim_handle();
3711
3712        struct NbaRecordingOps {
3713            calls: std::cell::RefCell<Vec<(StateCellId, u32)>>,
3714        }
3715        impl SchedulerOps for NbaRecordingOps {
3716            fn now(&self) -> crate::sched::SchedTime {
3717                crate::sched::SchedTime { ticks: 0 }
3718            }
3719            fn current_region(&self) -> Region {
3720                Region::Nba
3721            }
3722            fn stage_nba(
3723                &self,
3724                cell: StateCellId,
3725                _value: crate::state::FourStateValue,
3726                source: u32,
3727            ) {
3728                self.calls.borrow_mut().push((cell, source));
3729            }
3730            fn commit_blocking_write(
3731                &self,
3732                _cell: StateCellId,
3733                _value: crate::state::FourStateValue,
3734            ) {
3735            }
3736            fn request_finish(&self, _code: i32) {}
3737            fn defer_io(
3738                &self,
3739                _at: crate::sched::SchedTime,
3740                _region: Region,
3741                _cb: crate::sched::DeferredIoCallback,
3742            ) {
3743            }
3744            fn register_region_callback(
3745                &self,
3746                _region: Region,
3747                _at: crate::sched::SchedTime,
3748                _cb: Box<dyn crate::sched::RegionCallback>,
3749            ) {
3750            }
3751        }
3752
3753        let ops = NbaRecordingOps {
3754            calls: std::cell::RefCell::new(Vec::new()),
3755        };
3756
3757        // No CURRENT_PROCESS installed (the shape every direct-call test in
3758        // this file, bypassing ResumeDriver, produces): real_nba_enqueue
3759        // must fall back to the raw ABI `source` value (99) it was actually
3760        // called with.
3761        with_scheduler_ops(&ops, || {
3762            real_nba_enqueue(h, vj_abi::SlotId(0), 1, 99);
3763        });
3764        assert_eq!(
3765            *ops.calls.borrow(),
3766            vec![(cell, 99)],
3767            "with no CURRENT_PROCESS installed, real_nba_enqueue must fall back to the raw ABI \
3768             source value"
3769        );
3770        ops.calls.borrow_mut().clear();
3771
3772        // CURRENT_PROCESS installed (exactly as ResumeDriver::resume_for/
3773        // start install it around every real resume() call): real_nba_enqueue
3774        // must prefer it (7) over the raw ABI source (99) -- the one
3775        // observable difference this review fix actually produces.
3776        with_current_process(7, || {
3777            with_scheduler_ops(&ops, || {
3778                real_nba_enqueue(h, vj_abi::SlotId(0), 1, 99);
3779            });
3780        });
3781        assert_eq!(
3782            *ops.calls.borrow(),
3783            vec![(cell, 7)],
3784            "with CURRENT_PROCESS installed, real_nba_enqueue must prefer it (7) over the raw \
3785             ABI source (99)"
3786        );
3787
3788        // SAFETY: reclaims the leaked `Box<InstanceHandle>` `into_sim_handle`
3789        // minted above -- the same reclaim pattern `state::accessor_impl`'s
3790        // own tests use after every `into_sim_handle` call.
3791        unsafe {
3792            drop(Box::from_raw(h.0 as *mut InstanceHandle));
3793        }
3794    }
3795}