summaryrefslogtreecommitdiff
path: root/src/oracle/intr_cases.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/oracle/intr_cases.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/oracle/intr_cases.zig')
-rw-r--r--src/oracle/intr_cases.zig595
1 files changed, 595 insertions, 0 deletions
diff --git a/src/oracle/intr_cases.zig b/src/oracle/intr_cases.zig
new file mode 100644
index 0000000..78f2965
--- /dev/null
+++ b/src/oracle/intr_cases.zig
@@ -0,0 +1,595 @@
+//! The interrupt controller's side of the differential test.
+//!
+//! **What this can and cannot prove.** A register comparison is weaker evidence for an interrupt
+//! controller than for any other peripheral here, and pretending otherwise would be the worst thing
+//! this file could do. What it establishes is that for each operation below, this HAL leaves the
+//! same words behind that ESP-IDF's code does - the matrix address arithmetic, the `+ 16` offset,
+//! the two different priority encodings, the trigger encoding, and which bits each operation is
+//! allowed to disturb. What it cannot establish is that an interrupt is ever *taken*: that depends
+//! on mtvec, MTVT, mstatus.MIE, the trap entry's register save and the CLIC's arbitration, none of
+//! which appear in any register this harness photographs. The behavioural test is spelled out at
+//! the foot of this file and the parent must run it separately.
+//!
+//! **Three windows, three suites.** The controller is three disjoint pieces of address space:
+//! * the interrupt matrix at DR_REG_INTERRUPT_CORE0_BASE, 128 words, one per source;
+//! * the CLIC's global registers at 0x2080_0000, three words, the third being the threshold;
+//! * the CLIC's per-interrupt control file at 0x2080_1000, 48 words, one per CLIC ID.
+//! A single window spanning them would have to read about a thousand words of address space nothing
+//! is mapped at. Each descriptor below covers only registers these cases actually touch.
+//!
+//! **Nothing here enables interrupts.** No case calls `hal.intr.init()`, sets mstatus.MIE or writes
+//! mtvec; the cases enable *lines* at the CLIC, which with MIE clear is inert. `setup` clears MIE
+//! explicitly, so that is true even if something earlier in the image set it.
+//!
+//! **The two sides reach the hardware by different paths**, which is what makes this a test rather
+//! than a tautology: IDF's side goes through `src/oracle/intr_ref.c` into IDF's own inlines and
+//! macros, ours goes through `src/hal/intr.zig`, and the harness compares raw words rather than
+//! either side's read-back.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+const intr = hal.intr;
+const Reg = mmio.Reg;
+const Field = mmio.Field;
+
+// ---------------------------------------------------------------- ESP-IDF's side
+
+extern fn oracle_intr_intthresh_standard() c_int;
+extern fn oracle_intr_mintstatus_csr() c_int;
+extern fn oracle_intr_mtvt_csr() c_int;
+extern fn oracle_intr_nlbits() c_int;
+extern fn oracle_intr_ext_offset() c_int;
+extern fn oracle_intr_thresh_reg_addr() c_uint;
+extern fn oracle_intr_ctrl_reg_addr(clic_id: c_uint) c_uint;
+
+extern fn oracle_intr_route(intr_src: c_uint, line: c_uint) void;
+extern fn oracle_intr_unroute(intr_src: c_uint) void;
+extern fn oracle_intr_set_vectored(line: c_uint, vectored: c_int) void;
+extern fn oracle_intr_get_type(line: c_uint) c_int;
+extern fn oracle_intr_get_priority(line: c_uint) c_int;
+extern fn oracle_intr_enable(line: c_uint) void;
+extern fn oracle_intr_disable(line: c_uint) void;
+extern fn oracle_intr_set_type(line: c_uint, trig: c_uint) void;
+extern fn oracle_intr_set_priority(line: c_uint, priority: c_uint) void;
+extern fn oracle_intr_edge_ack(line: c_uint) void;
+extern fn oracle_intr_enabled_mask() c_uint;
+extern fn oracle_intr_set_threshold(level: c_uint) void;
+extern fn oracle_intr_get_threshold() c_uint;
+extern fn oracle_intr_set_mtvt(mtvt: c_uint) void;
+
+/// The constants ESP-IDF's half was compiled with. Every one of these is a way the experiment could
+/// be quietly meaningless, so the harness should print them rather than assume them:
+/// * `intthresh_standard` **must be 0**. A 1 means the C side switched to the `mintthresh` CSR,
+/// which this die does not implement, and the threshold comparison would be against a register
+/// the interrupt arbiter never reads. (`intr_ref.c` also makes this a compile error.)
+/// * `mintstatus_csr` must be 0x346 - the non-standard number for pre-v3 silicon. 0xFB1 would mean
+/// the rev-3 header path got selected.
+/// * `ext_offset` must be 16 and `nlbits` 3: all the arithmetic on both sides rests on those two.
+/// * `thresh_reg` must be 0x20800008, not a CSR number.
+pub const RefConfig = struct {
+ intthresh_standard: u32,
+ mintstatus_csr: u32,
+ mtvt_csr: u32,
+ nlbits: u32,
+ ext_offset: u32,
+ thresh_reg: u32,
+};
+
+pub fn refConfig() RefConfig {
+ return .{
+ .intthresh_standard = @intCast(oracle_intr_intthresh_standard()),
+ .mintstatus_csr = @intCast(oracle_intr_mintstatus_csr()),
+ .mtvt_csr = @intCast(oracle_intr_mtvt_csr()),
+ .nlbits = @intCast(oracle_intr_nlbits()),
+ .ext_offset = @intCast(oracle_intr_ext_offset()),
+ .thresh_reg = @intCast(oracle_intr_thresh_reg_addr()),
+ };
+}
+
+// ---------------------------------------------------------------- what is under test
+
+/// The source under test. A module-level variable because Zig has no closures and the harness holds
+/// plain `fn` pointers - the same reason `gpio_cases.zig:39` has one.
+pub var source: intr.Source = .tg1_t0;
+
+/// The external line under test, 0..31.
+pub var line: u5 = 5;
+
+/// Sources worth routing, chosen to exercise the address arithmetic rather than to be interesting:
+/// the first mapping register (`lp_rtc`, +0x000), the last one that exists on this die
+/// (`assist_debug`, +0x1FC), and three in between. A wrong scale factor or a wrong base would be
+/// invisible at `lp_rtc` and unmissable at `assist_debug`.
+///
+/// The three rev-3-only sources (IDs 133-135) are deliberately absent: their mapping registers are
+/// not implemented on pre-v3 silicon, so a comparison there would compare two reads of nothing.
+pub const sources = [_]intr.Source{ .lp_rtc, .i2c1, .tg1_t0, .gpio_intr3, .assist_debug };
+
+/// Lines worth testing: one in the low half, one in the high half. A line is offset by 16 before it
+/// indexes the CLIC, so line 24 lands at CLIC ID 40 - past the point where a missing offset would
+/// still have landed inside the 48-word table and gone unnoticed.
+pub const lines = [_]u5{ 5, 24 };
+
+// ---------------------------------------------------------------- addresses
+
+const matrix_base: u32 = @intCast(regs.DR_REG_INTERRUPT_CORE0_BASE);
+const clic_ctrl_base: u32 = @intCast(regs.DR_REG_CLIC_CTRL_BASE);
+
+const int_map = Field.of(regs.INTERRUPT_CORE0_UART0_INT_MAP_S, regs.INTERRUPT_CORE0_UART0_INT_MAP_V);
+const int_ctl = Field.of(regs.CLIC_INT_CTL_S, regs.CLIC_INT_CTL_V);
+const int_attr_trig = Field.of(regs.CLIC_INT_ATTR_TRIG_S, regs.CLIC_INT_ATTR_TRIG_V);
+const int_attr_shv = Field.of(regs.CLIC_INT_ATTR_SHV_S, regs.CLIC_INT_ATTR_SHV_V);
+const int_ie = Field.of(regs.CLIC_INT_IE_S, regs.CLIC_INT_IE_V);
+
+inline fn mapReg(source_id: u8) Reg {
+ return Reg.atAddress(matrix_base + 4 * @as(u32, source_id));
+}
+inline fn ctrlReg(clic_id: u32) Reg {
+ return Reg.atAddress(clic_ctrl_base + 4 * clic_id);
+}
+
+// ---------------------------------------------------------------- restore
+
+/// Reset value of the CLIC_INT_CTL priority field: 0x1f (`soc/clic_reg.h:70`). Restoring to 0 would
+/// be restoring to a state the hardware never boots in, and the two implementations would then be
+/// compared from a starting point neither of them produces.
+const ctl_reset: u32 = 0x1f;
+
+/// Bring all three blocks back to a known state between the two implementations.
+///
+/// Configuring rather than resetting, and here that is not a preference: neither the matrix nor the
+/// CLIC has a reset bit in HP_SYS_CLKRST, and there is no sound way for code to reset the interrupt
+/// controller of the core it is running on. Configuring is legitimate because every field this
+/// touches is plain read/write.
+///
+/// Three things this does that a narrower restore would not, each for a reason:
+///
+/// 1. **All five sources, not just the current one.** A case that wrote the wrong mapping register
+/// would otherwise leave that write behind; the next case's two snapshots would both inherit it
+/// and compare equal. The bug would be visible exactly once and then absorbed.
+///
+/// 2. **A sweep of all 128 mapping registers for anything pointing at a line under test.** This is
+/// what makes "nothing can assert into these lines during the run" true rather than hoped. The
+/// ROM bootloader is under no obligation to leave the matrix clear, and a live peripheral routed
+/// to CLIC ID 21 or 40 would set that line's pending bit between the two snapshots and read as a
+/// false difference. 128 reads is nothing; guessing is not free.
+///
+/// 3. **Both lines, not just the current one**, for the same reason as (1).
+fn restore() void {
+ for (sources) |s| intr.unroute(s);
+
+ // (2): detach anything at all that aims at a line this suite uses.
+ for (lines) |l| {
+ const id = @as(u32, l) + intr.ext_offset;
+ var src: u32 = 0;
+ while (src <= intr.max_source_id) : (src += 1) {
+ const r = mapReg(@intCast(src));
+ if (r.get(int_map) == id) r.modify(.{int_map.is(0)});
+ }
+ }
+
+ for (lines) |l| {
+ ctrlReg(@as(u32, l) + intr.ext_offset).modify(.{
+ int_ctl.is(ctl_reset),
+ int_attr_trig.is(0),
+ int_attr_shv.is(0),
+ int_ie.is(0),
+ });
+ }
+
+ intr.setThreshold(0);
+}
+
+/// Run once, before anything. Makes the "no interrupt can be taken during this suite" claim true
+/// rather than assumed: the cases enable CLIC lines, and an enabled line with MIE set would vector
+/// through whatever mtvec the bootloader happened to leave behind.
+fn setup() void {
+ intr.globalDisable();
+ restore();
+}
+
+// ---------------------------------------------------------------- the matrix suite
+
+/// The interrupt matrix: 128 mapping registers, source 0 at +0x000 through `assist_debug` at
+/// +0x1FC. The whole block is in the window deliberately - the cases touch five of the 128, and the
+/// other 123 are the point: a routing write that landed on the wrong register shows up as a
+/// difference in a word no case names.
+///
+/// No `volatile_words`. Each word is a 6-bit read/write field plus reserved bits; nothing here is
+/// read-to-clear, nothing self-clears, and no hardware writes these - they are pure configuration.
+///
+/// No `clock` either, and that is structural rather than lucky: the matrix and the CLIC are in the
+/// CPU's own clock domain and have no gate in HP_SYS_CLKRST, because a core cannot be allowed to
+/// gate off the block that delivers its own interrupts.
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "intr_matrix",
+ .base = matrix_base,
+ .words = 128,
+ .restore = .{ .configure = restore },
+ },
+ .setup = setup,
+ .cases = &.{
+ .{ .name = "route", .idf = idfRoute, .ours = ourRoute },
+ .{ .name = "unroute", .idf = idfUnroute, .ours = ourUnroute },
+ .{ .name = "route_rewrite", .idf = idfRouteTwice, .ours = ourRouteTwice },
+ .{ .name = "route_preserves_reserved", .idf = idfRouteOverJunk, .ours = ourRouteOverJunk },
+ },
+};
+
+// ---------------------------------------------------------------- the CLIC control suite
+
+/// The CLIC's per-interrupt control file: 48 words, one per CLIC ID, the 16 internal IDs included.
+/// They are in the window on purpose - every accessor in `hal/intr.zig` adds 16 to the caller's line
+/// number, and an implementation that forgot to would write into IDs 5 and 24 instead of 21 and 40.
+/// Both of those are inside this window and neither is excluded below, so a missing offset is a
+/// visible difference rather than silence.
+///
+/// **The pending bits, and why only three words are excluded.** CLIC_INT_IP is bit 0 of every one of
+/// these words and the hardware sets it on its own when a source asserts. For the 32 external IDs
+/// that cannot happen during this run: `restore` sweeps all 128 mapping registers and detaches
+/// anything aimed at a line under test, and the other 30 external lines have nothing routed to them
+/// that these cases did not route. The three excluded words are the standard RISC-V internal
+/// interrupts, whose pending bits are driven by the core's own timer and software-interrupt
+/// hardware rather than by the matrix, and which this file therefore cannot promise are quiet.
+/// Excluding them costs nothing: no operation here can reach an internal ID except by the off-by-16
+/// bug, and that bug lands on IDs 5 and 24, which are still compared.
+pub const clic_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "intr_clic",
+ .base = clic_ctrl_base,
+ .words = 48,
+ .volatile_words = &.{
+ 3, // machine software interrupt - IP driven by the msip mechanism
+ 7, // machine timer interrupt - IP driven by the core timer, which is running
+ 11, // machine external interrupt - IP driven from outside the matrix
+ },
+ .restore = .{ .configure = restore },
+ },
+ .setup = setup,
+ .cases = &.{
+ .{ .name = "enable", .arg = 1, .idf = idfEnable, .ours = ourEnable },
+ .{ .name = "disable", .arg = 0, .idf = idfDisable, .ours = ourDisable },
+ .{ .name = "enable_other_line", .idf = idfEnableOther, .ours = ourEnableOther },
+ .{ .name = "trigger_level", .arg = 0, .idf = idfTrigLevel, .ours = ourTrigLevel },
+ .{ .name = "trigger_rising", .arg = 1, .idf = idfTrigRising, .ours = ourTrigRising },
+ .{ .name = "trigger_falling", .arg = 3, .idf = idfTrigFalling, .ours = ourTrigFalling },
+ .{ .name = "priority", .arg = 7, .idf = idfPrio7, .ours = ourPrio7 },
+ .{ .name = "priority", .arg = 1, .idf = idfPrio1, .ours = ourPrio1 },
+ .{ .name = "priority", .arg = 0, .idf = idfPrio0, .ours = ourPrio0 },
+ .{ .name = "vectored_on", .arg = 1, .idf = idfVectoredOn, .ours = ourVectoredOn },
+ .{ .name = "vectored_off", .arg = 0, .idf = idfVectoredOff, .ours = ourVectoredOff },
+ .{ .name = "edge_ack", .idf = idfEdgeAck, .ours = ourEdgeAck },
+ .{ .name = "configure_line", .arg = 3, .idf = idfConfigure, .ours = ourConfigure },
+ },
+};
+
+// ---------------------------------------------------------------- the threshold suite
+
+/// The CLIC's three global registers, and the reason this file exists at all.
+///
+/// 0x2080_0000 CLIC_INT_CONFIG (R/W in MNLBITS, untouched here), 0x2080_0004 CLIC_INT_INFO (RO,
+/// reads 48 interrupts / 4 CTL bits), 0x2080_0008 CLIC_INT_THRESH. Three words, contiguous, all
+/// mapped - a window of exactly the registers involved, rather than a widened one that reaches the
+/// threshold by crossing 4 KiB of nothing.
+///
+/// **This is the die where the threshold is a register and not a CSR.** `soc/interrupt_reg.h:28-40`
+/// sets INTTHRESH_STANDARD 0 under CONFIG_ESP32P4_SELECTS_REV_LESS_V3, and `riscv/csr_clic.h:37-47`
+/// then never defines MINTTHRESH_CSR. A HAL that wrote CSR 0x347 instead would pass every other
+/// case in this file and fail every one of these three, which is exactly the discrimination the
+/// suite is for: the mistake is silent everywhere else.
+pub const thresh_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "intr_thresh",
+ .base = @intCast(regs.DR_REG_CLIC_BASE),
+ .words = 3,
+ .restore = .{ .configure = restoreThreshold },
+ },
+ .setup = setup,
+ .cases = &.{
+ .{ .name = "threshold", .arg = 0, .idf = idfThresh0, .ours = ourThresh0 },
+ .{ .name = "threshold", .arg = 3, .idf = idfThresh3, .ours = ourThresh3 },
+ .{ .name = "threshold", .arg = 7, .idf = idfThresh7, .ours = ourThresh7 },
+ },
+};
+
+
+/// Restored through ESP-IDF's side, never through the code under test. `differ.zig` runs restore,
+/// idf, snapshot, restore, ours, snapshot: with the HAL on both the restore and the "ours" side, a
+/// HAL function that does nothing leaves run B's snapshot equal to run A's and the case passes. That
+/// makes a suite blind to precisely the failure it was written to catch.
+fn restoreThreshold() void {
+ oracle_intr_set_threshold(0);
+}
+
+// ---------------------------------------------------------------- matrix cases
+
+fn idfRoute() void {
+ oracle_intr_route(@intFromEnum(source), line);
+}
+fn ourRoute() void {
+ intr.route(source, line);
+}
+
+fn idfUnroute() void {
+ oracle_intr_route(@intFromEnum(source), line);
+ oracle_intr_unroute(@intFromEnum(source));
+}
+fn ourUnroute() void {
+ intr.route(source, line);
+ intr.unroute(source);
+}
+
+/// Re-routing a source that is already routed. The mapping register is a read-modify-write of the
+/// low 6 bits (`interrupt_clic_ll.h:46`, RV_INT_MASK 63 at line 25), so the second write must
+/// *replace* the first rather than OR into it. An `|=` implementation passes the single-write case
+/// and fails this one: 31+16 = 47 or'd with 5+16 = 21 is 63, not 21.
+fn idfRouteTwice() void {
+ oracle_intr_route(@intFromEnum(source), 31);
+ oracle_intr_route(@intFromEnum(source), line);
+}
+fn ourRouteTwice() void {
+ intr.route(source, 31);
+ intr.route(source, line);
+}
+
+/// Route over a word whose reserved bits [31:6] are all set. Both sides must preserve them - IDF's
+/// REG_SET_BITS masks with 63, ours is a `Field` of the same width - and this is the case that says
+/// so rather than assuming it. A `write` where a `modify` belonged clears them.
+fn dirtyMapReg() void {
+ mapReg(@intFromEnum(source)).writeRaw(0xffff_ffc0);
+}
+fn idfRouteOverJunk() void {
+ dirtyMapReg();
+ oracle_intr_route(@intFromEnum(source), line);
+}
+fn ourRouteOverJunk() void {
+ dirtyMapReg();
+ intr.route(source, line);
+}
+
+// ---------------------------------------------------------------- CLIC control cases
+
+fn idfEnable() void {
+ oracle_intr_enable(line);
+}
+fn ourEnable() void {
+ intr.setEnabled(line, true);
+}
+
+fn idfDisable() void {
+ oracle_intr_enable(line);
+ oracle_intr_disable(line);
+}
+fn ourDisable() void {
+ intr.setEnabled(line, true);
+ intr.setEnabled(line, false);
+}
+
+fn otherLine() u5 {
+ return if (line == lines[0]) lines[1] else lines[0];
+}
+
+/// Enable the line under test and then enable and disable the *other* one. Catches an index bug
+/// that a single-line case cannot: both lines are inside the window, so touching the wrong one is a
+/// visible difference rather than an invisible no-op, and the enable/disable pair means the correct
+/// answer is "only the first line ends up enabled".
+fn idfEnableOther() void {
+ oracle_intr_enable(line);
+ oracle_intr_enable(otherLine());
+ oracle_intr_disable(otherLine());
+}
+fn ourEnableOther() void {
+ intr.setEnabled(line, true);
+ intr.setEnabled(otherLine(), true);
+ intr.setEnabled(otherLine(), false);
+}
+
+fn idfTrigLevel() void {
+ oracle_intr_set_type(line, 0);
+}
+fn ourTrigLevel() void {
+ intr.setTrigger(line, .level);
+}
+
+fn idfTrigRising() void {
+ oracle_intr_set_type(line, 1);
+}
+fn ourTrigRising() void {
+ intr.setTrigger(line, .rising_edge);
+}
+
+/// 0b11, falling edge. IDF's own non-ROM helper only ever writes rising - `esp_tee_rv_utils.h:97-98`
+/// is a TODO saying as much - so this is an encoding IDF documents (`clic_reg.h:84-88`) but does not
+/// exercise, and its reference is the transcribed REG_SET_FIELD from `esp_rom_clic.c:21` rather than
+/// a call into IDF. That makes it the case here most likely to disagree, which is why it is here.
+fn idfTrigFalling() void {
+ oracle_intr_set_type(line, 3);
+}
+fn ourTrigFalling() void {
+ intr.setTrigger(line, .falling_edge);
+}
+
+fn idfPrio7() void {
+ oracle_intr_set_priority(line, 7);
+}
+fn ourPrio7() void {
+ intr.setPriority(line, 7);
+}
+
+fn idfPrio1() void {
+ oracle_intr_set_priority(line, 1);
+}
+fn ourPrio1() void {
+ intr.setPriority(line, 1);
+}
+
+/// Priority 0 writes 0x00 over the reset value 0x1f, so this is the case that proves the low
+/// `8 - NLBITS` bits are being *cleared*. If either side padded them with ones - which is what the
+/// *threshold* encoding does, `csr_clic.h:59` - the two words would differ by 0x1F000000 and by
+/// nothing else. Priorities 1 and 7 both leave those bits zero either way and cannot see it.
+fn idfPrio0() void {
+ oracle_intr_set_priority(line, 0);
+}
+fn ourPrio0() void {
+ intr.setPriority(line, 0);
+}
+
+fn idfVectoredOn() void {
+ oracle_intr_set_vectored(line, 1);
+}
+fn ourVectoredOn() void {
+ intr.setVectored(line, true);
+}
+
+fn idfVectoredOff() void {
+ oracle_intr_set_vectored(line, 1);
+ oracle_intr_set_vectored(line, 0);
+}
+fn ourVectoredOff() void {
+ intr.setVectored(line, true);
+ intr.setVectored(line, false);
+}
+
+/// Writing 1 to IP. With nothing routed to this line there is nothing pending to clear, so what is
+/// compared is the *store*: which word, which bit, and whether the surrounding fields survive. That
+/// the hardware then reads that write as an acknowledgement is behavioural and out of reach here.
+fn idfEdgeAck() void {
+ oracle_intr_set_type(line, 1);
+ oracle_intr_edge_ack(line);
+}
+fn ourEdgeAck() void {
+ intr.setTrigger(line, .rising_edge);
+ intr.edgeAck(line);
+}
+
+/// The whole per-line configuration in one go. Our side goes through `configureLine`, not through
+/// four separate calls, because that function - not its pieces - is what a driver will use, and
+/// because four fields in one word is where an ordering bug or a `write` that should have been a
+/// `modify` shows up while each field alone still passes.
+fn idfConfigure() void {
+ oracle_intr_set_type(line, 1);
+ oracle_intr_set_priority(line, 3);
+ oracle_intr_set_vectored(line, 0);
+ oracle_intr_enable(line);
+}
+fn ourConfigure() void {
+ intr.configureLine(line, .{
+ .handler = noopHandler,
+ .trigger = .rising_edge,
+ .priority = 3,
+ .vectored = false,
+ });
+}
+
+/// Installed and never called: `configureLine` requires a handler and the differ never sets MIE.
+/// Its address goes into a RAM array no descriptor's window covers, so it cannot perturb a
+/// comparison.
+fn noopHandler(_: u5) void {}
+
+// ---------------------------------------------------------------- threshold cases
+
+fn idfThresh0() void {
+ oracle_intr_set_threshold(0);
+}
+fn ourThresh0() void {
+ intr.setThreshold(0);
+}
+
+fn idfThresh3() void {
+ oracle_intr_set_threshold(3);
+}
+fn ourThresh3() void {
+ intr.setThreshold(3);
+}
+
+fn idfThresh7() void {
+ oracle_intr_set_threshold(7);
+}
+fn ourThresh7() void {
+ intr.setThreshold(7);
+}
+
+// ---------------------------------------------------------------------------------------------
+// THE BEHAVIOURAL TEST - which the parent must run, because this file cannot.
+// ---------------------------------------------------------------------------------------------
+//
+// Everything above compares register *state*. None of it touches the parts of this peripheral that
+// exist only while an interrupt is in flight: mtvec's mode bits, the MTVT fetch, the CLIC's
+// arbitration against the threshold, mcause's EXCCODE, the trap entry's register save, and `mret`.
+// A HAL that passes every case above and still never delivers an interrupt is entirely possible -
+// it is in fact the expected failure mode, because the single most likely mistake here (writing the
+// `mintthresh` CSR instead of CLIC_INT_THRESH_REG) leaves no trace in any register.
+//
+// The cheap test, with the numbers it needs:
+//
+// 1. `hal.clkrst.init(.timg1)` - clock on, reset pulsed, flash-boot protection cleared. TIMG's
+// reset re-arms the flash-boot watchdog; skipping the clear reboots the board a second later
+// with nothing on the console to explain it.
+// 2. Arm TIMG1 timer 0 for a one-shot alarm a few milliseconds out, alarm enabled, and the
+// timer's own interrupt enable set (TIMG_T0_INT_ENA).
+// 3. `hal.intr.init()` - fills the 48-entry vector table with the trap entry, writes MTVT
+// (CSR 0x307), writes mtvec = trap_entry | 3, and opens the threshold to 0.
+// 4. `hal.intr.attach(.tg1_t0, 5, .{ .handler = h, .trigger = .level, .priority = 1 })`.
+// `.tg1_t0` is source ID 49, so the mapping register is DR_REG_INTERRUPT_CORE0_BASE + 0xC4 and
+// the value written is 5 + 16 = 21. Priority 1 against threshold 0 is the minimum that is not
+// masked: the threshold comparison is inclusive, so priority 0 would never fire.
+// 5. The handler increments a counter and **clears TIMG1's interrupt status**. That is mandatory
+// for a level source: the CLIC has no acknowledge for level, so a handler that returns without
+// clearing the peripheral re-enters immediately and the board sits inside the trap entry with
+// the console silent. That failure looks exactly like a crash and is not one.
+// 6. `hal.intr.globalEnable()`, spin ~50 ms, `hal.intr.globalDisable()`.
+//
+// Pass is `counter == 1` **and** `hal.intr.spurious == 0`. Both halves matter: a counter of 1 with a
+// non-zero spurious count means an interrupt also arrived on a line nobody claimed, i.e. a matrix
+// write went somewhere unintended.
+//
+// Diagnostics worth printing on failure, because they separate the ways this can go wrong:
+// * `hal.intr.getThreshold()` beside `oracle_intr_get_threshold()` - a disagreement means the
+// threshold mechanism is the fault, which is what this die's non-standard CLIC invites.
+// * `hal.intr.routedLine(.tg1_t0)` - null means the matrix write missed.
+// * `hal.intr.isPending(5)` with the counter at 0 - the CLIC latched it and the core never took
+// it, so the fault is mtvec, MTVT or MIE, and is neither the matrix nor the threshold.
+// * TIMG1's raw interrupt status - if that is 0 the timer never fired and the test is measuring
+// something else entirely.
+//
+// A second, sharper test once the first passes: set the threshold to 7 *before* enabling, confirm
+// the counter stays 0 while `isPending(5)` becomes 1, then drop the threshold to 0 and confirm the
+// pending interrupt is delivered. That is the only way to show the memory-mapped threshold register
+// is the one the arbiter actually reads, and it is the claim this whole file is least able to
+// support on its own.
+
+// ---------------------------------------------------------------------------------------------
+
+test "the line-to-CLIC-ID offset the cases assume is the one the header defines" {
+ try std.testing.expectEqual(@as(u32, 16), intr.ext_offset);
+ try std.testing.expectEqual(@as(u32, 48), intr.total_ids);
+ // Both test lines land inside the 48-word CLIC window, which is what makes an off-by-16 in
+ // hal/intr.zig visible to the harness rather than silent...
+ for (lines) |l| try std.testing.expect(@as(u32, l) + intr.ext_offset < intr.total_ids);
+ // ...and neither un-offset line is one of the three words excluded as volatile, or the bug
+ // would land in a word the harness ignores.
+ for (lines) |l| for (clic_suite.descriptor.volatile_words) |w| try std.testing.expect(w != l);
+}
+
+test "the matrix window covers every source the cases route" {
+ for (sources) |s| {
+ try std.testing.expect(!s.isRev3Only());
+ try std.testing.expect(@intFromEnum(s) < suite.descriptor.words);
+ }
+ // The extremes really are in the set - that is the point of the choice.
+ try std.testing.expectEqual(@as(u8, 0), @intFromEnum(sources[0]));
+ try std.testing.expectEqual(@as(u8, 127), @intFromEnum(sources[sources.len - 1]));
+}
+
+test "the threshold window is the register block, not a CSR, and holds all three words" {
+ try std.testing.expectEqual(@as(u32, 0x2080_0000), thresh_suite.descriptor.base);
+ // CLIC_INT_THRESH_REG is the third word. If this ever stops being true the window is wrong.
+ try std.testing.expectEqual(
+ @as(u32, 0x2080_0008),
+ thresh_suite.descriptor.base + 4 * (thresh_suite.descriptor.words - 1),
+ );
+}