summaryrefslogtreecommitdiff
path: root/src/oracle
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
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')
-rw-r--r--src/oracle/all.zig51
-rw-r--r--src/oracle/clkrst_cases.zig131
-rw-r--r--src/oracle/clkrst_ref.c55
-rw-r--r--src/oracle/differ_types.zig72
-rw-r--r--src/oracle/gpio_cases.zig233
-rw-r--r--src/oracle/gpio_ref.c116
-rw-r--r--src/oracle/i2c_cases.zig627
-rw-r--r--src/oracle/i2c_ref.c262
-rw-r--r--src/oracle/intr_cases.zig595
-rw-r--r--src/oracle/intr_ref.c211
-rw-r--r--src/oracle/ledc_cases.zig513
-rw-r--r--src/oracle/ledc_ref.c210
-rw-r--r--src/oracle/oracle_sdkconfig.h43
-rw-r--r--src/oracle/runtime_ref.c19
-rw-r--r--src/oracle/sdmmc_cases.zig567
-rw-r--r--src/oracle/sdmmc_ref.c262
-rw-r--r--src/oracle/timg_cases.zig435
-rw-r--r--src/oracle/timg_ref.c197
-rw-r--r--src/oracle/uart_cases.zig326
-rw-r--r--src/oracle/uart_ref.c163
20 files changed, 5088 insertions, 0 deletions
diff --git a/src/oracle/all.zig b/src/oracle/all.zig
new file mode 100644
index 0000000..9537eac
--- /dev/null
+++ b/src/oracle/all.zig
@@ -0,0 +1,51 @@
+//! Every peripheral registered with the differential harness.
+//!
+//! One line per peripheral. The harness walks this list, so adding a peripheral to the oracle is
+//! three new files (`<name>_ref.c`, `<name>_cases.zig`, `src/hal/<name>.zig`) plus one line here.
+
+pub const types = @import("differ_types.zig");
+
+pub const gpio = @import("gpio_cases.zig");
+pub const clkrst = @import("clkrst_cases.zig");
+pub const timg = @import("timg_cases.zig");
+pub const uart = @import("uart_cases.zig");
+pub const intr = @import("intr_cases.zig");
+pub const ledc = @import("ledc_cases.zig");
+pub const i2c = @import("i2c_cases.zig");
+pub const sdmmc = @import("sdmmc_cases.zig");
+
+/// The suites, in the order they run.
+///
+/// GPIO first: the console's own pins live in that block, so a failure there explains failures
+/// everywhere else. `clkrst` last of the always-on set, because its cases deliberately gate
+/// peripherals off and its restore is what puts them back.
+pub const suites = [_]types.Suite{
+ gpio.suite,
+ gpio.iomux_suite,
+ timg.suite,
+ uart.suite,
+ intr.suite,
+ intr.clic_suite,
+ intr.thresh_suite,
+ // LEDC's state is not contiguous, so it comes as four windows rather than one: the block
+ // itself, the gamma RAM aperture (whose restore has to zero the RAM, because a peripheral reset
+ // does not), the GPIO words its pin routing touches, and the one HP_SYS_CLKRST word the P4
+ // moved its clock mux into.
+ ledc.suite,
+ ledc.gamma_suite,
+ ledc.routing_suite,
+ ledc.clock_suite,
+ // I2C likewise needs two: half of setBusTiming lands outside the I2C block, because the
+ // controller-clock divider is in HP_SYS_CLKRST. At 10 kHz that divider is 4, so an
+ // implementation that wrote all ten timing registers perfectly and the divider not at all would
+ // run the bus four times too fast and pass every case in the first suite.
+ i2c.suite,
+ i2c.clock_suite,
+ // SDMMC, likewise in two windows: the controller block, and the host clock generator that the
+ // P4 put in HP_SYS_CLKRST rather than in the peripheral. At 40 MHz the whole division happens
+ // in the second one, so a suite that covered only the first would pass on a bus running four
+ // times too fast.
+ sdmmc.suite,
+ sdmmc.clock_suite,
+ clkrst.suite,
+};
diff --git a/src/oracle/clkrst_cases.zig b/src/oracle/clkrst_cases.zig
new file mode 100644
index 0000000..f004dd4
--- /dev/null
+++ b/src/oracle/clkrst_cases.zig
@@ -0,0 +1,131 @@
+//! HP_SYS_CLKRST's side of the differential: the clock-gate and reset pairing table.
+//!
+//! This suite exists because of a bug that a hardware test failed to catch. `src/hal/clkrst.zig`
+//! maps each peripheral to the register and bit that gate and reset it, and every row of that table
+//! is a transcription from ESP-IDF's LL source - the field macros do not record which register they
+//! live in, so there is nothing to derive it from. Two rows were wrong: timg0, timg1, systimer and
+//! twai0 had their APB clock enables in `PERI_CLK_CTRL21` instead of `SOC_CLK_CTRL2`, so
+//! `setClockEnabled` wrote a bit of an unrelated register.
+//!
+//! `examples/halcheck.zig` printed exactly the expected `twai0 boot=0 on=1 off=0` throughout,
+//! because `isClockEnabled` read back the same wrong bit `setClockEnabled` had just written. A
+//! self-consistent test proves the two halves of your own code agree; it does not prove either one
+//! touches the hardware, and that one would have passed with the chip unplugged.
+//!
+//! ESP-IDF reaches these bits through its own generated struct definitions - a genuinely independent
+//! path - so this comparison is the check a read-back cannot be.
+//!
+//! Not covered here: TWAI0. Its bus clock is the one that is gated off at power-on, which makes it
+//! the interesting case, but ESP-IDF's TWAI bus-clock LL takes a controller handle this suite has no
+//! business constructing. The four rows below share the two registers TWAI0's row uses, so a
+//! transcription error in it would have to be independent of theirs to survive.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+extern fn oracle_clkrst_timg_bus_clock(group: c_uint, enable: c_int) void;
+extern fn oracle_clkrst_timg_reset(group: c_uint) void;
+extern fn oracle_clkrst_systimer_bus_clock(enable: c_int) void;
+extern fn oracle_clkrst_systimer_reset() void;
+extern fn oracle_clkrst_uart_bus_clock(port: c_uint, enable: c_int) void;
+
+/// Known state: every peripheral this suite touches with its bus clock on, which is also the state
+/// the chip powers up in ("All peripheral clocks are default enabled after chip is powered on",
+/// esp_system/port/soc/esp32p4/clk.c:200). Nothing else in the block is touched - UART0's gates in
+/// particular, because that is the console this result is printed over.
+/// Restore through ESP-IDF's LL, never through the code under test.
+///
+/// This suite exists to catch a `setClockEnabled` that writes the wrong register. Restoring with
+/// `hal.clkrst.setClockEnabled` defeated exactly that: `differ.zig` runs restore, idf, snapshot,
+/// restore, ours, snapshot, so with the HAL on both the restore and the "ours" side, a
+/// `setClockEnabled` that did nothing at all would leave run B's snapshot equal to run A's and pass
+/// all six clock cases. Which is how the original bug - four peripherals' gate bits in
+/// PERI_CLK_CTRL21 instead of SOC_CLK_CTRL2 - could have survived this suite too.
+fn restore() void {
+ oracle_clkrst_timg_bus_clock(1, 1);
+ oracle_clkrst_systimer_bus_clock(1);
+ oracle_clkrst_uart_bus_clock(1, 1);
+}
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "clkrst",
+ .base = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL1_REG - 0x18), // block base
+ // 0x00 through HP_RST_EN2 at +0xC8: covers SOC_CLK_CTRL1/2 (+0x18, +0x1c), every
+ // PERI_CLK_CTRL register, and all three HP_RST_EN registers. Everything either
+ // implementation could plausibly hit is inside this window, which is the property that
+ // makes a difference detectable rather than merely absent.
+ .words = 52,
+ .restore = .{ .configure = restore },
+ },
+ .cases = &.{
+ // Disable first in each pair: the restored state has them on, so "disable" is the operation
+ // with an observable effect and "enable" would otherwise be a no-op comparison.
+ .{ .name = "timg1_bus_clock", .arg = 0, .idf = idfTimgOff, .ours = ourTimgOff },
+ .{ .name = "systimer_bus_clock", .arg = 0, .idf = idfSystimerOff, .ours = ourSystimerOff },
+ .{ .name = "uart1_bus_clock", .arg = 0, .idf = idfUartOff, .ours = ourUartOff },
+ .{ .name = "timg1_reset", .idf = idfTimgReset, .ours = ourTimgReset },
+ .{ .name = "systimer_reset", .idf = idfSystimerReset, .ours = ourSystimerReset },
+ // Last, so the block is left with everything on regardless of which side ran last.
+ .{ .name = "timg1_bus_clock", .arg = 1, .idf = idfTimgOn, .ours = ourTimgOn },
+ .{ .name = "systimer_bus_clock", .arg = 1, .idf = idfSystimerOn, .ours = ourSystimerOn },
+ .{ .name = "uart1_bus_clock", .arg = 1, .idf = idfUartOn, .ours = ourUartOn },
+ },
+};
+
+fn idfTimgOff() void {
+ oracle_clkrst_timg_bus_clock(1, 0);
+}
+fn ourTimgOff() void {
+ hal.clkrst.setClockEnabled(.timg1, false);
+}
+fn idfTimgOn() void {
+ oracle_clkrst_timg_bus_clock(1, 1);
+}
+fn ourTimgOn() void {
+ hal.clkrst.setClockEnabled(.timg1, true);
+}
+fn idfSystimerOff() void {
+ oracle_clkrst_systimer_bus_clock(0);
+}
+fn ourSystimerOff() void {
+ hal.clkrst.setClockEnabled(.systimer, false);
+}
+fn idfSystimerOn() void {
+ oracle_clkrst_systimer_bus_clock(1);
+}
+fn ourSystimerOn() void {
+ hal.clkrst.setClockEnabled(.systimer, true);
+}
+fn idfUartOff() void {
+ oracle_clkrst_uart_bus_clock(1, 0);
+}
+fn ourUartOff() void {
+ hal.clkrst.setClockEnabled(.uart1, false);
+}
+fn idfUartOn() void {
+ oracle_clkrst_uart_bus_clock(1, 1);
+}
+fn ourUartOn() void {
+ hal.clkrst.setClockEnabled(.uart1, true);
+}
+
+/// The reset pairing, which is the other half of the table and the half that was right. IDF pulses
+/// the bit and returns; so does ours, except for the timer groups, where it additionally clears the
+/// flash-boot watchdog protection that the reset re-arms - so a difference in the WDT register is
+/// expected and lives outside this window, while HP_RST_EN1 itself must match.
+fn idfTimgReset() void {
+ oracle_clkrst_timg_reset(1);
+}
+fn ourTimgReset() void {
+ hal.clkrst.resetPeripheral(.timg1);
+}
+fn idfSystimerReset() void {
+ oracle_clkrst_systimer_reset();
+}
+fn ourSystimerReset() void {
+ hal.clkrst.resetPeripheral(.systimer);
+}
diff --git a/src/oracle/clkrst_ref.c b/src/oracle/clkrst_ref.c
new file mode 100644
index 0000000..b35436c
--- /dev/null
+++ b/src/oracle/clkrst_ref.c
@@ -0,0 +1,55 @@
+/* ESP-IDF's own bus-clock and reset control, as the reference for src/hal/clkrst.zig.
+ *
+ * This suite exists because of a specific bug. `clkrst.zig` maps each peripheral to the register and
+ * bit that gate and reset it, and that table is hand-written: the field macros do not say which
+ * register they live in, so every row is a transcription from ESP-IDF's LL source. Two rows were
+ * wrong. The second batch - timg0, timg1, systimer and twai0 - had their APB clock enables in
+ * HP_SYS_CLKRST_PERI_CLK_CTRL21 instead of SOC_CLK_CTRL2, so `setClockEnabled` poked a bit of an
+ * unrelated register.
+ *
+ * It survived a hardware test, which is the point. `examples/halcheck.zig` printed exactly the
+ * expected `twai0 boot=0 on=1 off=0`, because the write and the read-back both went through the same
+ * wrong address: a self-consistent test that would have passed with the chip unplugged.
+ *
+ * IDF reaches these bits by a completely independent path - its own generated struct definitions -
+ * so comparing against it is the check that a read-back cannot be.
+ */
+
+/* IDF shadows every clock/reset LL function with a macro that references this identifier, which it
+ * deliberately never defines, so that an unguarded call fails to compile: the only legal caller
+ * holds a spinlock. There is no FreeRTOS here and core 1 is held in reset at power-on, so declaring
+ * the name is exactly as safe as the lock would be. IDF's own bootloader does the same thing
+ * (bootloader_support/src/bootloader_console.c:53). */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+/* IDF uses a second name for the same trick on the peripherals whose gate lives in a register shared
+ * with the CPU's own clocking - systimer among them. Same reasoning applies. */
+static int __DECLARE_RCC_RC_ATOMIC_ENV __attribute__((unused));
+
+#include "hal/timg_ll.h"
+#include "hal/systimer_ll.h"
+#include "hal/uart_ll.h"
+
+void oracle_clkrst_timg_bus_clock(unsigned group, int enable)
+{
+ _timg_ll_enable_bus_clock(group, enable != 0);
+}
+
+void oracle_clkrst_timg_reset(unsigned group)
+{
+ _timg_ll_reset_register(group);
+}
+
+void oracle_clkrst_systimer_bus_clock(int enable)
+{
+ systimer_ll_enable_bus_clock(enable != 0);
+}
+
+void oracle_clkrst_systimer_reset(void)
+{
+ systimer_ll_reset_register();
+}
+
+void oracle_clkrst_uart_bus_clock(unsigned port, int enable)
+{
+ _uart_ll_enable_bus_clock(port, enable != 0);
+}
diff --git a/src/oracle/differ_types.zig b/src/oracle/differ_types.zig
new file mode 100644
index 0000000..6f8e03c
--- /dev/null
+++ b/src/oracle/differ_types.zig
@@ -0,0 +1,72 @@
+//! The contract between the differential harness and a peripheral under test.
+//!
+//! Adding a peripheral to the oracle is three files and no edits to the harness:
+//!
+//! src/oracle/<name>_ref.c external-linkage wrappers over ESP-IDF's `*_ll.h` functions
+//! src/oracle/<name>_cases.zig a `descriptor` and a `cases` array, both of the types below
+//! src/hal/<name>.zig this project's implementation, which is what is being tested
+//!
+//! The harness then, for every case: brings the peripheral to a known state, runs ESP-IDF's version,
+//! photographs the register block, restores, runs ours, photographs again, and compares.
+
+/// Everything the harness needs to test a peripheral without breaking the board.
+pub const Peripheral = struct {
+ name: [*:0]const u8,
+
+ /// First address of the register block, and how many 32-bit words to compare.
+ base: u32,
+ words: u32,
+
+ /// Word offsets that must never be *read*, because reading them changes hardware state.
+ ///
+ /// This cannot be derived from the headers: `UART_FIFO_REG` sits at offset 0 of every UART
+ /// block, its only field is annotated `RO`, and reading it pops the RX FIFO. A generic
+ /// block-snapshot loop over a UART eats received bytes - including on the console.
+ no_read: []const u32 = &.{},
+
+ /// Word offsets whose value legitimately changes between two runs: counters, FIFO depths, live
+ /// input levels. Compared they would produce noise, so they are excluded.
+ volatile_words: []const u32 = &.{},
+
+ /// The bus-clock enable bit that must read 1 for a snapshot to mean anything.
+ ///
+ /// Reading a clock-gated block does not fault and does not return zeros - it returns the last
+ /// value latched, so two snapshots of a gated peripheral can compare *equal* while describing
+ /// nothing. The harness checks this before every comparison and fails the case if it is clear.
+ clock: ?Bit = null,
+
+ /// How to return the peripheral to a known state between the two implementations.
+ restore: Restore,
+
+ pub const Bit = struct { reg: u32, bit: u5 };
+
+ pub const Restore = union(enum) {
+ /// Pulse the peripheral's reset bit in HP_SYS_CLKRST. The only sound restore for a block
+ /// with write-to-trigger or write-only fields, because it is what the datasheet defines the
+ /// reset values against. Writing a snapshot back is *not* an option: ~10% of this chip's
+ /// fields perform an action when written, and writing one saved word back to a UART's
+ /// offset 0 transmits a character.
+ reset_bit: Bit,
+ /// A function that configures the block to a fixed state. For peripherals with no reset bit
+ /// of their own (GPIO, IO_MUX) or where resetting would take the console with it (UART0).
+ configure: *const fn () void,
+ };
+};
+
+/// One operation, expressed twice: ESP-IDF's way and ours. They must be the same operation with the
+/// same arguments, or the comparison means nothing.
+pub const Case = struct {
+ name: [*:0]const u8,
+ /// Printed with the result, so a failure names the arguments that produced it.
+ arg: u32 = 0,
+ idf: *const fn () void,
+ ours: *const fn () void,
+};
+
+/// What a `<name>_cases.zig` module must expose.
+pub const Suite = struct {
+ descriptor: Peripheral,
+ cases: []const Case,
+ /// Run once before the suite: bring the peripheral far enough up that its registers are live.
+ setup: ?*const fn () void = null,
+};
diff --git a/src/oracle/gpio_cases.zig b/src/oracle/gpio_cases.zig
new file mode 100644
index 0000000..ea32eff
--- /dev/null
+++ b/src/oracle/gpio_cases.zig
@@ -0,0 +1,233 @@
+//! GPIO's side of the differential test: the same operations expressed as ESP-IDF's LL calls and as
+//! this project's HAL calls.
+//!
+//! GPIO is restored by configuring rather than by resetting. It has no reset bit of its own in
+//! HP_SYS_CLKRST, and the pads are the board's wiring - the console's own pins are in this block, so
+//! a reset here would take the console with it. Configuring is sound for GPIO specifically because
+//! every field in the block is plain read/write: there is nothing self-clearing to restore.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+extern fn oracle_gpio_uses_rom_api() c_int;
+extern fn oracle_gpio_set_level(pin: c_uint, level: c_uint) void;
+extern fn oracle_gpio_output_enable(pin: c_uint) void;
+extern fn oracle_gpio_output_disable(pin: c_uint) void;
+extern fn oracle_gpio_input_enable(pin: c_uint) void;
+extern fn oracle_gpio_input_disable(pin: c_uint) void;
+extern fn oracle_gpio_func_sel(pin: c_uint, func: c_uint) void;
+extern fn oracle_gpio_set_drive(pin: c_uint, strength: c_uint) void;
+extern fn oracle_gpio_pullup_en(pin: c_uint) void;
+extern fn oracle_gpio_pullup_dis(pin: c_uint) void;
+extern fn oracle_gpio_pulldown_en(pin: c_uint) void;
+extern fn oracle_gpio_pulldown_dis(pin: c_uint) void;
+extern fn oracle_gpio_matrix_out(pin: c_uint, signal: c_uint) void;
+extern fn oracle_gpio_od_enable(pin: c_uint) void;
+extern fn oracle_gpio_od_disable(pin: c_uint) void;
+
+/// Whether ESP-IDF's LL was compiled to call the mask ROM instead of writing registers. Must be 0,
+/// or the differential is comparing this HAL against `rom_gpio_set_output_level` rather than against
+/// IDF's register sequence. Governed by src/oracle/oracle_sdkconfig.h.
+pub fn usesRomApi() bool {
+ return oracle_gpio_uses_rom_api() != 0;
+}
+
+/// The pin under test. A module-level variable because Zig has no closures and the harness stores
+/// plain `fn` pointers: a comptime-specialised pair per pin would compare code this project does not
+/// ship instead of the code it does.
+pub var pin: u8 = 20;
+
+/// Pins worth testing. 20 is the board's LED pin and 33 is a free header pin above the 32-boundary
+/// where this peripheral's bank arithmetic changes. GPIO54 is deliberately absent: it is this
+/// board's ESP32-C6 reset line, held high by an external pull-up, and driving it resets the radio.
+pub const pins = [_]u8{ 20, 33 };
+
+/// Restore, built from register macros only.
+///
+/// Nothing here may call the code under test. `differ.zig` runs restore, idf, snapshot, restore,
+/// ours, snapshot - so if restore is written with the HAL, run B starts from whatever IDF just wrote
+/// and a HAL function that does nothing at all compares equal. This suite used to restore with
+/// `hal.gpio.outputDisable` and `hal.gpio.setLow`, which made `output_disable` and `set_level(0)`
+/// no-op-versus-no-op: they could not fail.
+///
+/// It must also be *total* over everything any case touches. Leaving `GPIO_PIN{n}_REG` alone made
+/// both `open_drain` cases vacuous, because run B inherited run A's pad_driver bit.
+fn restore() void {
+ const b: u5 = @intCast(if (pin < 32) pin else pin - 32);
+ const m = @as(u32, 1) << b;
+ const enable_w1tc = if (pin < 32) regs.GPIO_ENABLE_W1TC_REG else regs.GPIO_ENABLE1_W1TC_REG;
+ const out_w1tc = if (pin < 32) regs.GPIO_OUT_W1TC_REG else regs.GPIO_OUT1_W1TC_REG;
+ mmio.Reg.atAddress(@intCast(enable_w1tc)).writeRaw(m);
+ mmio.Reg.atAddress(@intCast(out_w1tc)).writeRaw(m);
+ // The IO MUX pad word, the matrix output selector, and the GPIO block's own per-pin register.
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0)) + 4 * @as(u32, pin)).writeRaw(0);
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.GPIO_FUNC0_OUT_SEL_CFG_REG)) + 4 * @as(u32, pin))
+ .writeRaw(@intCast(regs.SIG_GPIO_OUT_IDX));
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.GPIO_PIN0_REG)) + 4 * @as(u32, pin)).writeRaw(0);
+}
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "gpio",
+ .base = @intCast(regs.GPIO_OUT_REG - 4), // GPIO_BT_SELECT_REG sits at +0x00
+ // 0x640 bytes. The window has to reach 0x558 + 4*57, where the matrix's per-pad output
+ // configuration lives: a first version stopped at 0x1C0 and was blind to a real bug in
+ // exactly those words - it saw the redundant GPIO_ENABLE write but not the wrong OEN_SEL
+ // that made it necessary.
+ .words = 400,
+ .volatile_words = &.{
+ (0x03c - 0x000) / 4, // GPIO_IN - reflects the outside world, which moves
+ (0x040 - 0x000) / 4, // GPIO_IN1
+ },
+ .restore = .{ .configure = restore },
+ },
+ .cases = &.{
+ .{ .name = "set_level", .arg = 1, .idf = idfSetHigh, .ours = ourSetHigh },
+ .{ .name = "set_level", .arg = 0, .idf = idfSetLow, .ours = ourSetLow },
+ .{ .name = "output_enable", .idf = idfOutEnable, .ours = ourOutEnable },
+ .{ .name = "output_disable", .idf = idfOutDisable, .ours = ourOutDisable },
+ .{ .name = "input_enable", .idf = idfInEnable, .ours = ourInEnable },
+ .{ .name = "input_disable", .idf = idfInDisable, .ours = ourInDisable },
+ .{ .name = "func_sel_gpio", .arg = 1, .idf = idfFuncGpio, .ours = ourFuncGpio },
+ .{ .name = "drive", .arg = 3, .idf = idfDriveStrong, .ours = ourDriveStrong },
+ .{ .name = "drive", .arg = 0, .idf = idfDriveWeakest, .ours = ourDriveWeakest },
+ .{ .name = "pull_up", .idf = idfPullUp, .ours = ourPullUp },
+ .{ .name = "pull_down", .idf = idfPullDown, .ours = ourPullDown },
+ .{ .name = "pull_none", .idf = idfPullNone, .ours = ourPullNone },
+ .{ .name = "matrix_out", .arg = 43, .idf = idfMatrixOut, .ours = ourMatrixOut },
+ // Open drain lives in the GPIO block's per-pin register, not the IO MUX pad register, and
+ // had no accessor until the I2C port needed one - that bus is wired-AND, and a pin left
+ // push-pull shorts it against another device's driver.
+ .{ .name = "open_drain", .arg = 1, .idf = idfOdOn, .ours = ourOdOn },
+ .{ .name = "open_drain", .arg = 0, .idf = idfOdOff, .ours = ourOdOff },
+ },
+};
+
+/// The IO MUX, which the GPIO block's window does not reach.
+///
+/// Every pad-configuration function on this chip writes `IO_MUX.gpio[n]` at
+/// PERIPHS_IO_MUX_U_PAD_GPIO0 = 0x500E1004 + 4*pin, and the GPIO block's compared window ends at
+/// 0x500E063F - 0xC00 bytes short. So `input_enable`, `input_disable`, `func_sel`, both `drive`
+/// cases and all three `pull` cases were comparing two identical snapshots of a register file none
+/// of them touches: 8 operations across 2 pins, 16 of the suite's cases, structurally unable to
+/// fail. They are the same cases; only the window is different.
+pub const iomux_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "iomux",
+ .base = @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0),
+ .words = 57, // one per pad, GPIO0..GPIO56
+ .restore = .{ .configure = restoreIomux },
+ },
+ .cases = &.{
+ .{ .name = "input_enable", .idf = idfInEnable, .ours = ourInEnable },
+ .{ .name = "input_disable", .idf = idfInDisable, .ours = ourInDisable },
+ .{ .name = "func_sel_gpio", .arg = 1, .idf = idfFuncGpio, .ours = ourFuncGpio },
+ .{ .name = "drive", .arg = 3, .idf = idfDriveStrong, .ours = ourDriveStrong },
+ .{ .name = "drive", .arg = 0, .idf = idfDriveWeakest, .ours = ourDriveWeakest },
+ .{ .name = "pull_up", .idf = idfPullUp, .ours = ourPullUp },
+ .{ .name = "pull_down", .idf = idfPullDown, .ours = ourPullDown },
+ .{ .name = "pull_none", .idf = idfPullNone, .ours = ourPullNone },
+ },
+};
+
+fn restoreIomux() void {
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0)) + 4 * @as(u32, pin)).writeRaw(0);
+}
+
+fn idfSetHigh() void {
+ oracle_gpio_set_level(pin, 1);
+}
+fn ourSetHigh() void {
+ hal.gpio.setHigh(pin);
+}
+fn idfSetLow() void {
+ oracle_gpio_set_level(pin, 0);
+}
+fn ourSetLow() void {
+ hal.gpio.setLow(pin);
+}
+fn idfOutEnable() void {
+ oracle_gpio_output_enable(pin);
+}
+fn ourOutEnable() void {
+ hal.gpio.outputEnable(pin);
+}
+fn idfOutDisable() void {
+ oracle_gpio_output_disable(pin);
+}
+fn ourOutDisable() void {
+ hal.gpio.outputDisable(pin);
+}
+fn idfInEnable() void {
+ oracle_gpio_input_enable(pin);
+}
+fn ourInEnable() void {
+ hal.gpio.setInputEnable(pin, true);
+}
+fn idfInDisable() void {
+ oracle_gpio_input_disable(pin);
+}
+fn ourInDisable() void {
+ hal.gpio.setInputEnable(pin, false);
+}
+fn idfFuncGpio() void {
+ oracle_gpio_func_sel(pin, 1);
+}
+fn ourFuncGpio() void {
+ hal.gpio.setFunction(pin, .gpio);
+}
+fn idfDriveStrong() void {
+ oracle_gpio_set_drive(pin, 3);
+}
+fn ourDriveStrong() void {
+ hal.gpio.setDrive(pin, .strong);
+}
+fn idfDriveWeakest() void {
+ oracle_gpio_set_drive(pin, 0);
+}
+fn ourDriveWeakest() void {
+ hal.gpio.setDrive(pin, .weakest);
+}
+fn idfPullUp() void {
+ oracle_gpio_pullup_en(pin);
+ oracle_gpio_pulldown_dis(pin);
+}
+fn ourPullUp() void {
+ hal.gpio.setPull(pin, .up);
+}
+fn idfPullDown() void {
+ oracle_gpio_pulldown_en(pin);
+ oracle_gpio_pullup_dis(pin);
+}
+fn ourPullDown() void {
+ hal.gpio.setPull(pin, .down);
+}
+fn idfPullNone() void {
+ oracle_gpio_pullup_dis(pin);
+ oracle_gpio_pulldown_dis(pin);
+}
+fn ourPullNone() void {
+ hal.gpio.setPull(pin, .none);
+}
+fn idfMatrixOut() void {
+ oracle_gpio_matrix_out(pin, 43);
+}
+fn ourMatrixOut() void {
+ hal.gpio.matrixOut(pin, 43);
+}
+
+fn idfOdOn() void {
+ oracle_gpio_od_enable(pin);
+}
+fn ourOdOn() void {
+ hal.gpio.setOpenDrain(pin, true);
+}
+fn idfOdOff() void {
+ oracle_gpio_od_disable(pin);
+}
+fn ourOdOff() void {
+ hal.gpio.setOpenDrain(pin, false);
+}
diff --git a/src/oracle/gpio_ref.c b/src/oracle/gpio_ref.c
new file mode 100644
index 0000000..b4070a2
--- /dev/null
+++ b/src/oracle/gpio_ref.c
@@ -0,0 +1,116 @@
+/* The reference implementation, which is ESP-IDF's own.
+ *
+ * ESP-IDF's `*_ll.h` headers are `static inline` functions over the same registers this project's
+ * Zig HAL drives. Compiled by Zig's clang for riscv32-freestanding they link into the same image as
+ * the Zig code, which is what makes a differential test possible at all: one binary, one boot, one
+ * set of clocks, both implementations, and the diff taken on the die.
+ *
+ * These wrappers exist only to give the inline functions external linkage so Zig can call them.
+ * There is no logic here - anything clever in this file would be a third implementation to doubt.
+ */
+
+/* IDF's clock and reset LL functions are shadowed by a wrapper macro that references
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere; its purpose is to make an
+ * unguarded call fail to compile, because the only legal caller holds a spinlock. There is no
+ * FreeRTOS here, and core 1 is held in reset at power-on, so declaring the name is exactly as safe
+ * as the spinlock would be - and it is what IDF's own bootloader does
+ * (bootloader_support/src/bootloader_console.c:53 declares a dummy local for the same reason). */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+#include "hal/gpio_ll.h"
+#include "soc/gpio_struct.h"
+#include "soc/io_mux_struct.h"
+
+/* Whether this translation unit was built with the ROM path switched on. The harness prints it, so
+ * that a differential run can never silently be "my registers versus the mask ROM". */
+int oracle_gpio_uses_rom_api(void)
+{
+#if HAL_CONFIG(GPIO_USE_ROM_API)
+ return 1;
+#else
+ return 0;
+#endif
+}
+
+void oracle_gpio_set_level(unsigned pin, unsigned level)
+{
+ gpio_ll_set_level(&GPIO, pin, level);
+}
+
+int oracle_gpio_get_level(unsigned pin)
+{
+ return gpio_ll_get_level(&GPIO, pin);
+}
+
+void oracle_gpio_output_enable(unsigned pin)
+{
+ gpio_ll_output_enable(&GPIO, pin);
+}
+
+void oracle_gpio_output_disable(unsigned pin)
+{
+ gpio_ll_output_disable(&GPIO, pin);
+}
+
+void oracle_gpio_input_enable(unsigned pin)
+{
+ gpio_ll_input_enable(&GPIO, pin);
+}
+
+void oracle_gpio_input_disable(unsigned pin)
+{
+ gpio_ll_input_disable(&GPIO, pin);
+}
+
+void oracle_gpio_func_sel(unsigned pin, unsigned func)
+{
+ gpio_ll_func_sel(&GPIO, pin, func);
+}
+
+void oracle_gpio_set_drive(unsigned pin, unsigned strength)
+{
+ gpio_ll_set_drive_capability(&GPIO, pin, (gpio_drive_cap_t)strength);
+}
+
+void oracle_gpio_pullup_en(unsigned pin)
+{
+ gpio_ll_pullup_en(&GPIO, pin);
+}
+
+void oracle_gpio_pullup_dis(unsigned pin)
+{
+ gpio_ll_pullup_dis(&GPIO, pin);
+}
+
+void oracle_gpio_pulldown_en(unsigned pin)
+{
+ gpio_ll_pulldown_en(&GPIO, pin);
+}
+
+void oracle_gpio_pulldown_dis(unsigned pin)
+{
+ gpio_ll_pulldown_dis(&GPIO, pin);
+}
+
+/* Open drain, which lives in the GPIO block's own per-pin register (GPIO_PINn_PAD_DRIVER) rather
+ * than in the IO MUX pad register - a different register file for the same pad. The I2C HAL needs it
+ * because that bus is wired-AND, and a pin left push-pull shorts a shared bus against another
+ * device's driver. One bit, and expensive to get wrong. */
+void oracle_gpio_od_enable(unsigned pin)
+{
+ gpio_ll_od_enable(&GPIO, pin);
+}
+
+void oracle_gpio_od_disable(unsigned pin)
+{
+ gpio_ll_od_disable(&GPIO, pin);
+}
+
+/* Route a peripheral signal to a pad through the GPIO matrix. This is the one GPIO operation with a
+ * real sequence rather than a single field write, and therefore the one where a write-trace
+ * comparison can find something a state comparison cannot. */
+void oracle_gpio_matrix_out(unsigned pin, unsigned signal)
+{
+ gpio_ll_set_output_signal_matrix_source(&GPIO, pin, signal, false);
+ gpio_ll_set_output_enable_ctrl(&GPIO, pin, true, false);
+}
diff --git a/src/oracle/i2c_cases.zig b/src/oracle/i2c_cases.zig
new file mode 100644
index 0000000..0546066
--- /dev/null
+++ b/src/oracle/i2c_cases.zig
@@ -0,0 +1,627 @@
+//! I2C's side of the differential test: the same operations expressed as ESP-IDF's LL calls and as
+//! this project's HAL calls.
+//!
+//! Two suites, because this peripheral's state lives in two register blocks that are 0x24000 bytes
+//! apart and the harness compares one window per suite:
+//!
+//! * `suite` - the I2C0 block itself (0x500C4000, 128 words). Timing, FIFOs, the command list,
+//! the filter, the timeout.
+//! * `clock_suite` - the two HP_SYS_CLKRST words that hold I2C's controller clock: source select,
+//! clock enable and the divider, for *both* ports (HP_SYS_CLKRST_PERI_CLK_CTRL10/11). Without
+//! this second window the divider half of `setBusTiming` would be untested, because the divider
+//! write does not land in the I2C block at all. Registering only the first suite would leave a
+//! bus that is a factor of `clkm_div` too fast with nothing to notice.
+//!
+//! Restore differs between the two, and both choices are forced:
+//!
+//! * The I2C block is restored by its **reset bit**. It has three write-to-trigger fields
+//! (`trans_start`, `fsm_rst`, `conf_upgate`) and a self-setting `command_done` per slot, so
+//! writing a snapshot back would trigger a transaction. HP_SYS_CLKRST's reset bit is what the
+//! datasheet defines the reset values against, and I2C0 carries nothing this board needs - no
+//! console, no flash - so pulsing it is safe.
+//! * The clock words cannot be reset that way: they are in HP_SYS_CLKRST, not in the I2C block, and
+//! PERI_CLK_CTRL11 also holds three I2S0_RX clock fields. Restore there is a configure function
+//! that writes only I2C's own fields back to their documented reset value of zero.
+//!
+//! The restore function deliberately builds its field descriptors from the macros itself rather than
+//! calling into `hal.i2c`: a restore that shared the HAL's idea of where a field lives would agree
+//! with a HAL that had it wrong, and the case would pass while configuring the wrong bits. Same
+//! reason `i2c_ref.c` maps command *kinds* to IDF's `I2C_LL_CMD_*` macros instead of taking an
+//! opcode number from Zig.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+const Reg = mmio.Reg;
+const Field = mmio.Field;
+
+extern fn oracle_i2c_enable_bus_clock(port: c_int, enable: c_int) void;
+extern fn oracle_i2c_reset_register(port: c_int) void;
+extern fn oracle_i2c_enable_controller_clock(port: c_int, enable: c_int) void;
+extern fn oracle_i2c_set_source_clk(port: c_int, src: c_int) void;
+extern fn oracle_i2c_master_init(port: c_int) void;
+extern fn oracle_i2c_set_mode_master(port: c_int) void;
+extern fn oracle_i2c_enable_pins_open_drain(port: c_int, enable_od: c_int) void;
+extern fn oracle_i2c_update(port: c_int) void;
+extern fn oracle_i2c_fsm_rst(port: c_int) void;
+extern fn oracle_i2c_set_bus_timing(port: c_int, source_hz: c_uint, bus_hz: c_uint) void;
+extern fn oracle_i2c_set_start_timing(port: c_int, setup: c_int, hold: c_int) void;
+extern fn oracle_i2c_set_stop_timing(port: c_int, setup: c_int, hold: c_int) void;
+extern fn oracle_i2c_set_sda_timing(port: c_int, sample: c_int, hold: c_int) void;
+extern fn oracle_i2c_set_tout(port: c_int, tout: c_int) void;
+extern fn oracle_i2c_set_scl_timeout_us(port: c_int, source_hz: c_uint, timeout_us: c_uint) void;
+extern fn oracle_i2c_set_filter(port: c_int, filter_num: c_uint) void;
+extern fn oracle_i2c_txfifo_rst(port: c_int) void;
+extern fn oracle_i2c_rxfifo_rst(port: c_int) void;
+extern fn oracle_i2c_enable_fifo_mode(port: c_int, fifo_mode_en: c_int) void;
+extern fn oracle_i2c_set_fifo_thresholds(port: c_int, tx_empty: c_uint, rx_full: c_uint) void;
+extern fn oracle_i2c_write_txfifo_pattern(port: c_int, len: c_uint) void;
+extern fn oracle_i2c_write_cmd(
+ port: c_int,
+ slot: c_int,
+ kind: c_uint,
+ byte_num: c_uint,
+ ack_en: c_int,
+ ack_exp: c_int,
+ ack_val: c_int,
+) void;
+extern fn oracle_i2c_clear_intr_mask(port: c_int, mask: c_uint) void;
+extern fn oracle_i2c_disable_intr_mask(port: c_int, mask: c_uint) void;
+extern fn oracle_i2c_get_hw_version(port: c_int) c_uint;
+extern fn oracle_i2c_cmd_reg_num() c_uint;
+extern fn oracle_i2c_fifo_len() c_uint;
+
+/// ESP-IDF's own view of two chip constants this HAL hard-codes. The harness prints them; a
+/// disagreement means `hal.i2c.cmd_slots` or `fifo_len` was read out of the wrong chip's header,
+/// which is a mistake no register comparison would ever show.
+pub fn idfCmdSlots() u32 {
+ return oracle_i2c_cmd_reg_num();
+}
+
+pub fn idfFifoLen() u32 {
+ return oracle_i2c_fifo_len();
+}
+
+pub fn hardwareVersion() u32 {
+ return oracle_i2c_get_hw_version(0);
+}
+
+comptime {
+ // These are constants in both implementations, so they can be checked here rather than on the
+ // die - but only against the *header*, which is why the runtime accessors above exist too.
+ if (hal.i2c.cmd_slots != 8) @compileError("this chip has eight command slots");
+ if (hal.i2c.fifo_len != 32) @compileError("this chip's I2C FIFO is 32 bytes");
+}
+
+// There is no module-level "port under test" variable here, unlike the GPIO suite's `pin`, and the
+// reason is in the descriptors: a `Peripheral` carries one `base` and one `clock`, both constants,
+// so the I2C-block suite is pinned to I2C0 by construction and running it "for port 1" would need a
+// second descriptor rather than a variable. Nothing is lost by that, because the only per-port
+// arithmetic in this peripheral is which HP_SYS_CLKRST field a port's clock lives in - and both
+// ports' fields are inside `clock_suite`'s two-word window, where the cases name the port directly.
+
+/// 40 MHz crystal, which is what `Timing.calculate` is fed on both sides. Not a measurement: the
+/// board's crystal, and the P4's only XTAL frequency.
+const source_hz: u32 = hal.i2c.xtal_hz;
+
+// --------------------------------------------------------------------------- the I2C0 block
+
+fn resetI2c0() void {
+ // Same pulse the harness's `.reset_bit` restore performs, for `setup` to use before the first
+ // case. Interrupt-masked because HP_RST_EN1 holds every peripheral's reset bit.
+ const guard = hal.clkrst.maskInterrupts();
+ defer guard.release();
+ const r = Reg.at(regs.HP_SYS_CLKRST_HP_RST_EN1_REG);
+ const bit = @as(u32, 1) << @intCast(regs.HP_SYS_CLKRST_REG_RST_EN_I2C0_S);
+ r.writeRaw(r.raw() | bit);
+ r.writeRaw(r.raw() & ~bit);
+}
+
+/// Bring I2C0 far enough up that its registers are live and its state machine is clocked.
+///
+/// The APB gate defaults to 1 on this chip so the registers are readable from boot, but the
+/// *controller* clock defaults to 0 - and that one is in HP_SYS_CLKRST, outside the block, so the
+/// reset-bit restore between cases does not disturb it.
+fn setupI2c0() void {
+ hal.clkrst.setClockEnabled(.i2c0, true);
+ hal.i2c.setControllerClockEnabled(0, true);
+ resetI2c0();
+}
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "i2c",
+ .base = @intCast(regs.I2C_SCL_LOW_PERIOD_REG(0)), // I2C0 + 0x000
+ // 128 words = 0x200 bytes, which is the whole instance: configuration and the command list
+ // end at +0x84, the version word is at +0xf8, and the two 32-byte FIFO RAMs are at +0x100
+ // (TX) and +0x180 (RX). The RAMs are in the window on purpose - a TX FIFO write is otherwise
+ // observable only as a count in I2C_SR, and a count is a much weaker witness than the bytes
+ // themselves. If those words ever turn out to read unstably in FIFO mode - ESP-IDF only ever
+ // touches them in non-FIFO mode - they belong in `volatile_words`, not out of the window.
+ .words = 128,
+ // Reading I2C_DATA_REG pops the RX FIFO. The register header gives no hint of it: the only
+ // field is annotated `HRO` and described as "Rx FIFO read data" (i2c_reg.h:464-474). What
+ // settles it is that `i2c_ll_read_rxfifo` reads this one address `len` times and expects
+ // `len` different bytes (i2c_ll.h:691-697), which is only possible if the read advances the
+ // FIFO - and `i2c_ll_write_txfifo` writes the same address to fill the *other* FIFO
+ // (i2c_ll.h:674-680). Same shape as UART_FIFO_REG. A snapshot loop that reads it would eat
+ // received bytes and desynchronise the read pointer under the case being measured.
+ .no_read = &.{hal.i2c.data_word_offset},
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL2_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_I2C0_APB_CLK_EN_S),
+ },
+ .restore = .{ .reset_bit = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_HP_RST_EN1_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_RST_EN_I2C0_S),
+ } },
+ },
+ .cases = &.{
+ // ---- bus timing. Five frequencies, chosen for the branches rather than for roundness.
+ // 100 kHz and 400 kHz are the two speeds every device supports; 1 MHz is fast-mode-plus,
+ // where half_cycle is down to 20 source cycles and the minus-one asymmetries dominate;
+ // 50 kHz and 10 kHz are on the other side of the 80 kHz boundary where the scl_wait_high
+ // split changes formula (i2c_ll.h:112-115); and 10 kHz is the one that needs a controller
+ // clock divider greater than 1 - the half that this window cannot see, which is what
+ // `clock_suite` is for.
+ .{ .name = "bus_timing_100k", .arg = 100_000, .idf = idfTiming100k, .ours = ourTiming100k },
+ .{ .name = "bus_timing_400k", .arg = 400_000, .idf = idfTiming400k, .ours = ourTiming400k },
+ .{ .name = "bus_timing_1M", .arg = 1_000_000, .idf = idfTiming1M, .ours = ourTiming1M },
+ .{ .name = "bus_timing_50k", .arg = 50_000, .idf = idfTiming50k, .ours = ourTiming50k },
+ .{ .name = "bus_timing_10k", .arg = 10_000, .idf = idfTiming10k, .ours = ourTiming10k },
+
+ // ---- master bring-up, and the open-drain polarity on its own.
+ .{ .name = "master_init", .idf = idfMasterInit, .ours = ourMasterInit },
+ .{ .name = "pins_open_drain", .arg = 1, .idf = idfOpenDrainOn, .ours = ourOpenDrainOn },
+ .{ .name = "pins_push_pull", .arg = 0, .idf = idfOpenDrainOff, .ours = ourOpenDrainOff },
+ .{ .name = "fifo_mode", .arg = 1, .idf = idfFifoMode, .ours = ourFifoMode },
+ .{ .name = "nonfifo_mode", .arg = 0, .idf = idfNonFifoMode, .ours = ourNonFifoMode },
+
+ // ---- FIFOs. The resets are two stores each (the bit is not self-clearing), so a
+ // half-done reset shows up as a FIFO held in reset rather than as a wrong value.
+ .{ .name = "txfifo_rst", .idf = idfTxFifoRst, .ours = ourTxFifoRst },
+ .{ .name = "rxfifo_rst", .idf = idfRxFifoRst, .ours = ourRxFifoRst },
+ .{ .name = "txfifo_write", .arg = 4, .idf = idfWrite4, .ours = ourWrite4 },
+ .{ .name = "txfifo_write", .arg = 31, .idf = idfWrite31, .ours = ourWrite31 },
+ .{ .name = "fifo_thresholds", .arg = 8, .idf = idfThresholds, .ours = ourThresholds },
+
+ // ---- filter. Three cases because "off" is not "on with a threshold of zero": both enables
+ // default to 1 with zero thresholds, so disabling has to clear the enables and leave the
+ // thresholds alone (i2c_ll.h:753-764).
+ .{ .name = "filter_7", .arg = 7, .idf = idfFilter7, .ours = ourFilter7 },
+ .{ .name = "filter_15", .arg = 15, .idf = idfFilter15, .ours = ourFilter15 },
+ .{ .name = "filter_off", .arg = 0, .idf = idfFilter0, .ours = ourFilter0 },
+
+ // ---- timeout. The field is five bits and holds an *exponent*: the bus times out after
+ // 2^value source-clock cycles, so 12 is 102 us at 40 MHz and 31 is the largest the register
+ // can hold. The third case goes through the microsecond conversion IDF's driver uses
+ // (i2c_ll.h:1060-1065) for its documented 2000 us default, which comes out as 17.
+ .{ .name = "tout_12", .arg = 12, .idf = idfTout12, .ours = ourTout12 },
+ .{ .name = "tout_31", .arg = 31, .idf = idfTout31, .ours = ourTout31 },
+ .{ .name = "scl_timeout_us", .arg = 2000, .idf = idfSclTimeoutUs, .ours = ourSclTimeoutUs },
+
+ // ---- the explicit timing setters, where IDF's minus-one convention is least uniform:
+ // start setup as given but start hold minus one, stop and sda both as given.
+ .{ .name = "start_timing", .arg = 7, .idf = idfStartTiming, .ours = ourStartTiming },
+ .{ .name = "stop_timing", .arg = 5, .idf = idfStopTiming, .ours = ourStopTiming },
+ .{ .name = "sda_timing", .arg = 11, .idf = idfSdaTiming, .ours = ourSdaTiming },
+
+ // ---- the command list, one opcode per slot. The IDF side names the opcode
+ // (`I2C_LL_CMD_*`) and the ours side names it too (`Op.restart`), so the *numbers* are never
+ // passed across: this chip's register header documents the pre-C3 numbering, and a test that
+ // handed the number over would agree with a wrong constant instead of catching it.
+ .{ .name = "cmd_restart", .arg = 0, .idf = idfCmdRestart, .ours = ourCmdRestart },
+ .{ .name = "cmd_write_ack", .arg = 5, .idf = idfCmdWrite, .ours = ourCmdWrite },
+ .{ .name = "cmd_read_ack", .arg = 3, .idf = idfCmdReadAck, .ours = ourCmdReadAck },
+ .{ .name = "cmd_read_nack", .arg = 1, .idf = idfCmdReadNack, .ours = ourCmdReadNack },
+ .{ .name = "cmd_stop", .arg = 0, .idf = idfCmdStop, .ours = ourCmdStop },
+ .{ .name = "cmd_end", .arg = 0, .idf = idfCmdEnd, .ours = ourCmdEnd },
+ .{ .name = "cmd_list_write", .arg = 4, .idf = idfCmdListWrite, .ours = ourCmdListWrite },
+
+ // ---- interrupt state. Not an interrupt-driven driver - this HAL polls - but the clear
+ // register is write-1-to-clear, so getting it wrong (a read-modify-write instead of a raw
+ // store) is a class of bug worth one case.
+ .{ .name = "clear_intr", .idf = idfClearIntr, .ours = ourClearIntr },
+ .{ .name = "disable_intr", .idf = idfDisableIntr, .ours = ourDisableIntr },
+ },
+ .setup = setupI2c0,
+};
+
+// ---- bus timing --------------------------------------------------------------------------------
+// Each pair is IDF's calculate-and-write (i2c_hal.c:27-32) against ours (hal.i2c.setBusTiming). The
+// comparison covers ten in-block registers at once, so a single wrong subtraction anywhere in the
+// derivation shows up here.
+
+fn idfTiming100k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 100_000);
+}
+fn ourTiming100k() void {
+ hal.i2c.setBusTiming(0, source_hz, 100_000);
+}
+fn idfTiming400k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 400_000);
+}
+fn ourTiming400k() void {
+ hal.i2c.setBusTiming(0, source_hz, 400_000);
+}
+fn idfTiming1M() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 1_000_000);
+}
+fn ourTiming1M() void {
+ hal.i2c.setBusTiming(0, source_hz, 1_000_000);
+}
+fn idfTiming50k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 50_000);
+}
+fn ourTiming50k() void {
+ hal.i2c.setBusTiming(0, source_hz, 50_000);
+}
+fn idfTiming10k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 10_000);
+}
+fn ourTiming10k() void {
+ hal.i2c.setBusTiming(0, source_hz, 10_000);
+}
+
+// ---- bring-up ----------------------------------------------------------------------------------
+
+fn idfMasterInit() void {
+ oracle_i2c_master_init(0);
+}
+fn ourMasterInit() void {
+ hal.i2c.initMaster(0);
+}
+fn idfOpenDrainOn() void {
+ oracle_i2c_enable_pins_open_drain(0, 1);
+}
+fn ourOpenDrainOn() void {
+ hal.i2c.setPinsOpenDrain(0, true);
+}
+fn idfOpenDrainOff() void {
+ oracle_i2c_enable_pins_open_drain(0, 0);
+}
+fn ourOpenDrainOff() void {
+ hal.i2c.setPinsOpenDrain(0, false);
+}
+fn idfFifoMode() void {
+ oracle_i2c_enable_fifo_mode(0, 1);
+}
+fn ourFifoMode() void {
+ hal.i2c.setFifoMode(0, true);
+}
+fn idfNonFifoMode() void {
+ oracle_i2c_enable_fifo_mode(0, 0);
+}
+fn ourNonFifoMode() void {
+ hal.i2c.setFifoMode(0, false);
+}
+
+// ---- FIFOs -------------------------------------------------------------------------------------
+
+fn idfTxFifoRst() void {
+ oracle_i2c_txfifo_rst(0);
+}
+fn ourTxFifoRst() void {
+ hal.i2c.resetTxFifo(0);
+}
+fn idfRxFifoRst() void {
+ oracle_i2c_rxfifo_rst(0);
+}
+fn ourRxFifoRst() void {
+ hal.i2c.resetRxFifo(0);
+}
+
+/// The same pattern `oracle_i2c_write_txfifo_pattern` generates: 0xA0 + i, so every byte differs
+/// from its neighbours and from the 0x00/0xFF a broken FIFO produces.
+const pattern: [hal.i2c.fifo_len]u8 = blk: {
+ var p: [hal.i2c.fifo_len]u8 = undefined;
+ for (&p, 0..) |*b, i| b.* = 0xA0 + @as(u8, @intCast(i));
+ break :blk p;
+};
+
+fn idfWrite4() void {
+ oracle_i2c_write_txfifo_pattern(0, 4);
+}
+fn ourWrite4() void {
+ hal.i2c.writeTxFifo(0, pattern[0..4]);
+}
+// 31 bytes rather than 32: one short of full, so the case cannot be passed by a FIFO that silently
+// wrapped and cannot trip the overflow protection either.
+fn idfWrite31() void {
+ oracle_i2c_write_txfifo_pattern(0, 31);
+}
+fn ourWrite31() void {
+ hal.i2c.writeTxFifo(0, pattern[0..31]);
+}
+fn idfThresholds() void {
+ oracle_i2c_set_fifo_thresholds(0, 8, 20);
+}
+fn ourThresholds() void {
+ hal.i2c.setFifoThresholds(0, 8, 20);
+}
+
+// ---- filter and timeout ------------------------------------------------------------------------
+
+fn idfFilter7() void {
+ oracle_i2c_set_filter(0, 7);
+}
+fn ourFilter7() void {
+ hal.i2c.setFilter(0, 7);
+}
+fn idfFilter15() void {
+ oracle_i2c_set_filter(0, 15);
+}
+fn ourFilter15() void {
+ hal.i2c.setFilter(0, 15);
+}
+fn idfFilter0() void {
+ oracle_i2c_set_filter(0, 0);
+}
+fn ourFilter0() void {
+ hal.i2c.setFilter(0, 0);
+}
+fn idfTout12() void {
+ oracle_i2c_set_tout(0, 12);
+}
+fn ourTout12() void {
+ hal.i2c.setTimeout(0, 12);
+}
+fn idfTout31() void {
+ oracle_i2c_set_tout(0, 31);
+}
+fn ourTout31() void {
+ hal.i2c.setTimeout(0, 31);
+}
+fn idfSclTimeoutUs() void {
+ oracle_i2c_set_scl_timeout_us(0, source_hz, 2000);
+}
+fn ourSclTimeoutUs() void {
+ hal.i2c.setTimeout(0, hal.i2c.timeoutExponent(source_hz, 2000));
+}
+
+// ---- explicit timing setters -------------------------------------------------------------------
+// The same registers `applyTiming` writes, but reached by IDF's three narrow setters, whose
+// minus-one convention is *different* from the one in the calculate-and-write path: start setup as
+// given and start hold minus one, both stop values as given, both sda values as given
+// (i2c_ll.h:452-486 against i2c_ll.h:210-217). Numbers with no relation to any real bus frequency,
+// so a HAL that quietly recomputed them from a frequency instead of writing what it was given would
+// show up here rather than passing.
+
+fn idfStartTiming() void {
+ oracle_i2c_set_start_timing(0, 7, 9);
+}
+fn ourStartTiming() void {
+ hal.i2c.setStartTiming(0, 7, 9);
+}
+fn idfStopTiming() void {
+ oracle_i2c_set_stop_timing(0, 5, 6);
+}
+fn ourStopTiming() void {
+ hal.i2c.setStopTiming(0, 5, 6);
+}
+fn idfSdaTiming() void {
+ oracle_i2c_set_sda_timing(0, 11, 3);
+}
+fn ourSdaTiming() void {
+ hal.i2c.setSdaTiming(0, 11, 3);
+}
+
+// ---- the command list --------------------------------------------------------------------------
+
+// Kind numbers as `i2c_ref.c` reads them: 0 restart, 1 write, 2 read, 3 stop, 4 end. Only the *kind*
+// crosses the language boundary; the C side turns it into an opcode with IDF's own macro.
+const kind_restart: c_uint = 0;
+const kind_write: c_uint = 1;
+const kind_read: c_uint = 2;
+const kind_stop: c_uint = 3;
+const kind_end: c_uint = 4;
+
+fn idfCmdRestart() void {
+ oracle_i2c_write_cmd(0, 0, kind_restart, 0, 0, 0, 0);
+}
+fn ourCmdRestart() void {
+ hal.i2c.writeCommand(0, 0, .{ .op = .restart });
+}
+fn idfCmdWrite() void {
+ oracle_i2c_write_cmd(0, 1, kind_write, 5, 1, 0, 0);
+}
+fn ourCmdWrite() void {
+ hal.i2c.writeCommand(0, 1, .{ .op = .write, .bytes = 5, .ack_check = true });
+}
+fn idfCmdReadAck() void {
+ oracle_i2c_write_cmd(0, 2, kind_read, 3, 0, 0, 0);
+}
+fn ourCmdReadAck() void {
+ hal.i2c.writeCommand(0, 2, .{ .op = .read, .bytes = 3, .ack_value = 0 });
+}
+fn idfCmdReadNack() void {
+ oracle_i2c_write_cmd(0, 3, kind_read, 1, 0, 0, 1);
+}
+fn ourCmdReadNack() void {
+ hal.i2c.writeCommand(0, 3, .{ .op = .read, .bytes = 1, .ack_value = 1 });
+}
+fn idfCmdStop() void {
+ oracle_i2c_write_cmd(0, 4, kind_stop, 0, 0, 0, 0);
+}
+fn ourCmdStop() void {
+ hal.i2c.writeCommand(0, 4, .{ .op = .stop });
+}
+fn idfCmdEnd() void {
+ oracle_i2c_write_cmd(0, 5, kind_end, 0, 0, 0, 0);
+}
+fn ourCmdEnd() void {
+ hal.i2c.writeCommand(0, 5, .{ .op = .end });
+}
+
+/// A whole list, in the shape `hal.i2c.write` builds for a four-byte transfer: RSTART, WRITE of
+/// 1 + 4 bytes with ACK checking, STOP. Slots 3 to 7 keep the reset value on both sides.
+fn idfCmdListWrite() void {
+ oracle_i2c_write_cmd(0, 0, kind_restart, 0, 0, 0, 0);
+ oracle_i2c_write_cmd(0, 1, kind_write, 5, 1, 0, 0);
+ oracle_i2c_write_cmd(0, 2, kind_stop, 0, 0, 0, 0);
+}
+fn ourCmdListWrite() void {
+ hal.i2c.writeCommands(0, &.{
+ .{ .op = .restart },
+ .{ .op = .write, .bytes = 5, .ack_check = true },
+ .{ .op = .stop },
+ });
+}
+
+// ---- interrupt state ---------------------------------------------------------------------------
+
+fn idfClearIntr() void {
+ oracle_i2c_clear_intr_mask(0, hal.i2c.all_interrupts);
+}
+fn ourClearIntr() void {
+ hal.i2c.clearInterrupts(0, hal.i2c.all_interrupts);
+}
+fn idfDisableIntr() void {
+ oracle_i2c_disable_intr_mask(0, hal.i2c.all_interrupts);
+}
+fn ourDisableIntr() void {
+ hal.i2c.disableInterrupts(0);
+}
+
+// ------------------------------------------------------- the clock domain: HP_SYS_CLKRST words
+//
+// Field descriptors built here rather than borrowed from hal.i2c, on purpose: the restore function
+// below must not share the HAL's idea of where these fields live, or a HAL with a field in the wrong
+// place would be restored consistently with its own mistake and every case would pass.
+
+const peri_clk_ctrl10 = Reg.at(regs.HP_SYS_CLKRST_PERI_CLK_CTRL10_REG);
+const peri_clk_ctrl11 = Reg.at(regs.HP_SYS_CLKRST_PERI_CLK_CTRL11_REG);
+
+const i2c0_clock_fields = [_]Field{
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C0_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_I2C0_CLK_SRC_SEL_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C0_CLK_EN_S, regs.HP_SYS_CLKRST_REG_I2C0_CLK_EN_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_NUM_S, regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_NUM_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_NUMERATOR_S, regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_NUMERATOR_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_DENOMINATOR_S, regs.HP_SYS_CLKRST_REG_I2C0_CLK_DIV_DENOMINATOR_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C1_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_I2C1_CLK_SRC_SEL_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C1_CLK_EN_S, regs.HP_SYS_CLKRST_REG_I2C1_CLK_EN_V),
+};
+
+const i2c1_divider_fields = [_]Field{
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_NUM_S, regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_NUM_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_NUMERATOR_S, regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_NUMERATOR_V),
+ Field.of(regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_DENOMINATOR_S, regs.HP_SYS_CLKRST_REG_I2C1_CLK_DIV_DENOMINATOR_V),
+};
+
+/// Zero every I2C clock field in the two words, which is their documented reset value
+/// (hp_sys_clkrst_reg.h: all ten default to 0), leaving everything else in those words alone.
+///
+/// "Everything else" is not empty: PERI_CLK_CTRL11 also holds `REG_I2S0_RX_CLK_EN` and
+/// `REG_I2S0_RX_CLK_SRC_SEL` in bits 24-26. Restoring by writing a whole word would take I2S0's
+/// receive clock with it, which is exactly the class of collateral damage the harness's
+/// no-write-back rule exists to prevent - so this is a masked read-modify-write, interrupt-masked
+/// because these registers are shared.
+fn restoreI2cClocks() void {
+ const guard = hal.clkrst.maskInterrupts();
+ defer guard.release();
+ var mask10: u32 = 0;
+ for (i2c0_clock_fields) |f| mask10 |= f.mask();
+ peri_clk_ctrl10.writeRaw(peri_clk_ctrl10.raw() & ~mask10);
+ var mask11: u32 = 0;
+ for (i2c1_divider_fields) |f| mask11 |= f.mask();
+ peri_clk_ctrl11.writeRaw(peri_clk_ctrl11.raw() & ~mask11);
+}
+
+/// I2C's controller clock: source, gate and divider, for both ports, in two words.
+///
+/// This is the other half of `setBusTiming`. The divider is what keeps `half_cycle` inside the
+/// nine-bit period fields at low bus frequencies - 10 kHz needs `clkm_div` 4 - so an implementation
+/// that wrote the timing registers correctly and the divider not at all would produce a bus four
+/// times too fast and pass every case in the suite above.
+pub const clock_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "i2c_clk",
+ .base = @intCast(regs.HP_SYS_CLKRST_PERI_CLK_CTRL10_REG),
+ // Two words: CTRL10 (all of I2C0's clock fields plus I2C1's source select and gate) and
+ // CTRL11 (I2C1's divider, and three I2S0_RX bits neither side touches).
+ .words = 2,
+ // No reset bit for HP_SYS_CLKRST, and no gate in front of it either: it is the block that
+ // holds every other block's gate.
+ .restore = .{ .configure = restoreI2cClocks },
+ },
+ .cases = &.{
+ .{ .name = "source_xtal", .arg = 0, .idf = idfSourceXtal0, .ours = ourSourceXtal0 },
+ .{ .name = "source_rc_fast", .arg = 0, .idf = idfSourceRcFast0, .ours = ourSourceRcFast0 },
+ .{ .name = "source_xtal_p1", .arg = 1, .idf = idfSourceXtal1, .ours = ourSourceXtal1 },
+ .{ .name = "source_rc_fast_p1", .arg = 1, .idf = idfSourceRcFast1, .ours = ourSourceRcFast1 },
+ .{ .name = "controller_clock_on", .arg = 0, .idf = idfCtrlClkOn0, .ours = ourCtrlClkOn0 },
+ .{ .name = "controller_clock_off", .arg = 0, .idf = idfCtrlClkOff0, .ours = ourCtrlClkOff0 },
+ .{ .name = "controller_clock_on_p1", .arg = 1, .idf = idfCtrlClkOn1, .ours = ourCtrlClkOn1 },
+ // The divider written by the same calculate-and-write pair as the timing cases, at the two
+ // frequencies either side of where clkm_div stops being 1.
+ .{ .name = "divider_100k", .arg = 100_000, .idf = idfDiv100k, .ours = ourDiv100k },
+ .{ .name = "divider_10k", .arg = 10_000, .idf = idfDiv10k, .ours = ourDiv10k },
+ // ... and on port 1, where the divider is in the *other* word from its own source select.
+ .{ .name = "divider_10k_p1", .arg = 10_000, .idf = idfDiv10kP1, .ours = ourDiv10kP1 },
+ },
+ .setup = null,
+};
+
+fn idfSourceXtal0() void {
+ oracle_i2c_set_source_clk(0, 0);
+}
+fn ourSourceXtal0() void {
+ hal.i2c.setSource(0, .xtal);
+}
+fn idfSourceRcFast0() void {
+ oracle_i2c_set_source_clk(0, 1);
+}
+fn ourSourceRcFast0() void {
+ hal.i2c.setSource(0, .rc_fast);
+}
+fn idfSourceXtal1() void {
+ oracle_i2c_set_source_clk(1, 0);
+}
+fn ourSourceXtal1() void {
+ hal.i2c.setSource(1, .xtal);
+}
+fn idfSourceRcFast1() void {
+ oracle_i2c_set_source_clk(1, 1);
+}
+fn ourSourceRcFast1() void {
+ hal.i2c.setSource(1, .rc_fast);
+}
+fn idfCtrlClkOn0() void {
+ oracle_i2c_enable_controller_clock(0, 1);
+}
+fn ourCtrlClkOn0() void {
+ hal.i2c.setControllerClockEnabled(0, true);
+}
+fn idfCtrlClkOff0() void {
+ oracle_i2c_enable_controller_clock(0, 0);
+}
+fn ourCtrlClkOff0() void {
+ hal.i2c.setControllerClockEnabled(0, false);
+}
+fn idfCtrlClkOn1() void {
+ oracle_i2c_enable_controller_clock(1, 1);
+}
+fn ourCtrlClkOn1() void {
+ hal.i2c.setControllerClockEnabled(1, true);
+}
+fn idfDiv100k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 100_000);
+}
+fn ourDiv100k() void {
+ hal.i2c.setBusTiming(0, source_hz, 100_000);
+}
+fn idfDiv10k() void {
+ oracle_i2c_set_bus_timing(0, source_hz, 10_000);
+}
+fn ourDiv10k() void {
+ hal.i2c.setBusTiming(0, source_hz, 10_000);
+}
+fn idfDiv10kP1() void {
+ oracle_i2c_set_bus_timing(1, source_hz, 10_000);
+}
+fn ourDiv10kP1() void {
+ hal.i2c.setBusTiming(1, source_hz, 10_000);
+}
diff --git a/src/oracle/i2c_ref.c b/src/oracle/i2c_ref.c
new file mode 100644
index 0000000..b569260
--- /dev/null
+++ b/src/oracle/i2c_ref.c
@@ -0,0 +1,262 @@
+/* I2C's reference implementation, which is ESP-IDF's own.
+ *
+ * These wrappers exist only to give IDF's `static inline` LL functions external linkage so Zig can
+ * call them. There is no logic here - anything clever in this file would be a third implementation
+ * to doubt - with one deliberate exception, `opcode_of`, explained where it appears.
+ */
+
+/* IDF's clock and reset LL functions are shadowed by a wrapper macro that references
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere; its purpose is to make an
+ * unguarded call fail to compile, because the only legal caller holds a spinlock. There is no
+ * FreeRTOS here and core 1 is held in reset at power-on, so declaring the name is exactly as safe as
+ * the spinlock would be - and it is what IDF's own bootloader does
+ * (bootloader_support/src/bootloader_console.c:53 declares a dummy local for the same reason).
+ *
+ * For I2C this covers four functions: i2c_ll_enable_bus_clock, i2c_ll_reset_register,
+ * i2c_ll_set_source_clk and the LP_I2C ones this file does not use. */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+#include "hal/i2c_ll.h"
+
+/* I2C0 and I2C1 are addresses PROVIDEd by soc/esp32p4/ld/esp32p4.peripherals.ld (lines 17-18:
+ * 0x500C4000 and 0x500C5000), which the build links when -Doracle is passed. Several LL functions
+ * dispatch on the *pointer* - i2c_ll_master_set_bus_timing compares `hw == &I2C0` to decide which
+ * HP_SYS_CLKRST divider to write - so passing the right one of these two is load-bearing, and a
+ * third port cannot be faked. */
+static i2c_dev_t *dev(int port)
+{
+ return (port == 0) ? &I2C0 : &I2C1;
+}
+
+/* ------------------------------------------------------------------ clocks, reset, bring-up */
+
+void oracle_i2c_enable_bus_clock(int port, int enable)
+{
+ i2c_ll_enable_bus_clock(port, enable != 0);
+}
+
+void oracle_i2c_reset_register(int port)
+{
+ i2c_ll_reset_register(port);
+}
+
+void oracle_i2c_enable_controller_clock(int port, int enable)
+{
+ i2c_ll_enable_controller_clock(dev(port), enable != 0);
+}
+
+/* src: 0 = XTAL, 1 = RC_FAST. Passed as IDF's own enum values rather than as the register bit, so a
+ * wrong bit polarity in the Zig would show up as a difference. */
+void oracle_i2c_set_source_clk(int port, int src)
+{
+ i2c_ll_set_source_clk(dev(port), (src == 1) ? I2C_CLK_SRC_RC_FAST : I2C_CLK_SRC_XTAL);
+}
+
+/* i2c_hal_master_init, i2c_hal.c:39-50, inlined here because i2c_hal.c is not linked into this
+ * image - only the LL headers are. The sequence is IDF's, unchanged. */
+void oracle_i2c_master_init(int port)
+{
+ i2c_dev_t *hw = dev(port);
+ i2c_ll_set_mode(hw, I2C_BUS_MODE_MASTER);
+ i2c_ll_enable_pins_open_drain(hw, true);
+ i2c_ll_enable_arbitration(hw, false);
+ i2c_ll_master_rx_full_ack_level(hw, false);
+ i2c_ll_set_data_mode(hw, I2C_DATA_MODE_MSB_FIRST, I2C_DATA_MODE_MSB_FIRST);
+ i2c_ll_txfifo_rst(hw);
+ i2c_ll_rxfifo_rst(hw);
+}
+
+void oracle_i2c_set_mode_master(int port)
+{
+ i2c_ll_set_mode(dev(port), I2C_BUS_MODE_MASTER);
+}
+
+void oracle_i2c_enable_pins_open_drain(int port, int enable_od)
+{
+ i2c_ll_enable_pins_open_drain(dev(port), enable_od != 0);
+}
+
+void oracle_i2c_update(int port)
+{
+ i2c_ll_update(dev(port));
+}
+
+void oracle_i2c_fsm_rst(int port)
+{
+ i2c_ll_master_fsm_rst(dev(port));
+}
+
+/* ------------------------------------------------------------------------------- bus timing */
+
+/* _i2c_hal_set_bus_timing, i2c_hal.c:27-32: calculate then write. The calculation
+ * (i2c_ll_master_cal_bus_clk, i2c_ll.h:104-128) is the part this project reimplements in Zig, and
+ * this is the only honest way to compare it - the computed struct never leaves the register file, so
+ * the comparison has to be of the registers it produced. */
+void oracle_i2c_set_bus_timing(int port, unsigned source_hz, unsigned bus_hz)
+{
+ i2c_hal_clk_config_t clk_cal = {0};
+ i2c_ll_master_cal_bus_clk(source_hz, bus_hz, &clk_cal);
+ i2c_ll_master_set_bus_timing(dev(port), &clk_cal);
+}
+
+/* The three timing setters that take explicit periods, which is where IDF's minus-one convention is
+ * least uniform: start_setup is written as given while start_hold is written minus one
+ * (i2c_ll.h:452-456), both stop values are written as given (i2c_ll.h:467-471), and so are both sda
+ * values (i2c_ll.h:482-486). None of that is derivable from the register headers. */
+void oracle_i2c_set_start_timing(int port, int setup, int hold)
+{
+ i2c_ll_master_set_start_timing(dev(port), setup, hold);
+}
+
+void oracle_i2c_set_stop_timing(int port, int setup, int hold)
+{
+ i2c_ll_master_set_stop_timing(dev(port), setup, hold);
+}
+
+void oracle_i2c_set_sda_timing(int port, int sample, int hold)
+{
+ i2c_ll_set_sda_timing(dev(port), sample, hold);
+}
+
+void oracle_i2c_set_tout(int port, int tout)
+{
+ i2c_ll_set_tout(dev(port), tout);
+}
+
+/* i2c_hal_master_set_scl_timeout_val, i2c_hal.c:66-70. */
+void oracle_i2c_set_scl_timeout_us(int port, unsigned source_hz, unsigned timeout_us)
+{
+ uint32_t reg_val = i2c_ll_calculate_timeout_us_to_reg_val(source_hz, timeout_us);
+ i2c_ll_set_tout(dev(port), reg_val);
+}
+
+void oracle_i2c_set_filter(int port, unsigned filter_num)
+{
+ i2c_ll_master_set_filter(dev(port), (uint8_t)filter_num);
+}
+
+/* --------------------------------------------------------------------------------- the FIFOs */
+
+void oracle_i2c_txfifo_rst(int port)
+{
+ i2c_ll_txfifo_rst(dev(port));
+}
+
+void oracle_i2c_rxfifo_rst(int port)
+{
+ i2c_ll_rxfifo_rst(dev(port));
+}
+
+void oracle_i2c_enable_fifo_mode(int port, int fifo_mode_en)
+{
+ i2c_ll_enable_fifo_mode(dev(port), fifo_mode_en != 0);
+}
+
+void oracle_i2c_set_fifo_thresholds(int port, unsigned tx_empty, unsigned rx_full)
+{
+ i2c_ll_set_txfifo_empty_thr(dev(port), (uint8_t)tx_empty);
+ i2c_ll_set_rxfifo_full_thr(dev(port), (uint8_t)rx_full);
+}
+
+/* A pattern rather than a caller-supplied buffer: the point is that both implementations push the
+ * same bytes through the same FIFO port, and a fixed generator makes the two sides impossible to
+ * accidentally disagree about. 0xA0 + i is chosen so every byte differs from its neighbours and from
+ * 0x00/0xFF, which are the values a broken FIFO produces. */
+void oracle_i2c_write_txfifo_pattern(int port, unsigned len)
+{
+ uint8_t buf[32];
+ if (len > sizeof(buf)) {
+ len = sizeof(buf);
+ }
+ for (unsigned i = 0; i < len; i++) {
+ buf[i] = (uint8_t)(0xA0 + i);
+ }
+ i2c_ll_write_txfifo(dev(port), buf, (uint8_t)len);
+}
+
+/* ------------------------------------------------------------------------- the command list */
+
+/* The one piece of logic in this file, and it is here on purpose: it maps a command *kind* to
+ * ESP-IDF's own `I2C_LL_CMD_*` macro, so the opcode number crosses the boundary as a name rather
+ * than as an integer. If the Zig side had the numbers wrong - and this chip's register header
+ * documents the pre-ESP32-C3 numbering, so getting them wrong is easy - passing the raw number
+ * through would make both sides agree on the same mistake and the differential would prove nothing.
+ *
+ * Kinds: 0 restart, 1 write, 2 read, 3 stop, 4 end. */
+static uint32_t opcode_of(unsigned kind)
+{
+ switch (kind) {
+ case 0: return I2C_LL_CMD_RESTART;
+ case 1: return I2C_LL_CMD_WRITE;
+ case 2: return I2C_LL_CMD_READ;
+ case 3: return I2C_LL_CMD_STOP;
+ default: return I2C_LL_CMD_END;
+ }
+}
+
+void oracle_i2c_write_cmd(int port, int slot, unsigned kind, unsigned byte_num,
+ int ack_en, int ack_exp, int ack_val)
+{
+ i2c_ll_hw_cmd_t cmd = {
+ .byte_num = byte_num,
+ .ack_en = (ack_en != 0),
+ .ack_exp = (ack_exp != 0),
+ .ack_val = (ack_val != 0),
+ .op_code = opcode_of(kind),
+ };
+ i2c_ll_master_write_cmd_reg(dev(port), cmd, slot);
+}
+
+/* ---------------------------------------------------------------------------- interrupt state */
+
+void oracle_i2c_clear_intr_mask(int port, unsigned mask)
+{
+ i2c_ll_clear_intr_mask(dev(port), mask);
+}
+
+void oracle_i2c_disable_intr_mask(int port, unsigned mask)
+{
+ i2c_ll_disable_intr_mask(dev(port), mask);
+}
+
+/* ------------------------------------------------------------------------------ observations */
+
+/* Not part of any comparison - these exist so the harness can print what the reference thinks the
+ * hardware says, next to what ours says, when a case fails. */
+unsigned oracle_i2c_get_hw_version(int port)
+{
+ return i2c_ll_get_hw_version(dev(port));
+}
+
+unsigned oracle_i2c_get_txfifo_len(int port)
+{
+ uint32_t len = 0;
+ i2c_ll_get_txfifo_len(dev(port), &len);
+ return len;
+}
+
+unsigned oracle_i2c_get_rxfifo_cnt(int port)
+{
+ uint32_t len = 0;
+ i2c_ll_get_rxfifo_cnt(dev(port), &len);
+ return len;
+}
+
+int oracle_i2c_get_tout(int port)
+{
+ int tout = 0;
+ i2c_ll_get_tout(dev(port), &tout);
+ return tout;
+}
+
+/* The chip's command-slot count as ESP-IDF's own header states it, so the Zig constant is checked
+ * against IDF rather than against a reading of IDF. */
+unsigned oracle_i2c_cmd_reg_num(void)
+{
+ return I2C_LL_CMD_REG_NUM;
+}
+
+unsigned oracle_i2c_fifo_len(void)
+{
+ return I2C_LL_FIFO_LEN;
+}
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),
+ );
+}
diff --git a/src/oracle/intr_ref.c b/src/oracle/intr_ref.c
new file mode 100644
index 0000000..ceeaa0d
--- /dev/null
+++ b/src/oracle/intr_ref.c
@@ -0,0 +1,211 @@
+/* ESP-IDF's own CLIC code, given external linkage so the differential harness can call it.
+ *
+ * The interrupt controller is the one peripheral where "wrap IDF's LL header" is not the whole
+ * story, and the reason is worth recording rather than papering over.
+ *
+ * ESP-IDF splits CLIC access across four places:
+ * 1. components/hal/include/hal/interrupt_clic_ll.h - the matrix route, SHV, and the two
+ * getters. Included below and wrapped directly; this is the LL proper.
+ * 2. components/riscv/include/esp_private/interrupt_clic.h - MTVT, the threshold, edge-ack and
+ * the enabled-mask scan, all `FORCE_INLINE_ATTR`. Also included below and wrapped directly.
+ * 3. the **mask ROM** - esprv_intc_int_enable / _set_priority / _set_type, aliased into
+ * esprv_int_* by components/riscv/ld/rom.api.ld. There is no C source for these, so they
+ * cannot be compiled into this image as a reference. Worse, one of them is *wrong* on this
+ * die: components/esp_rom/patches/esp_rom_clic.c:12-22 exists because the ROM's
+ * esprv_intc_int_set_type silently configures LEVEL when asked for EDGE, on exactly the
+ * CONFIG_ESP32P4_SELECTS_REV_LESS_V3 silicon this board is.
+ * 4. components/esp_tee/.../clic/esp_tee_rv_utils.h - a non-ROM implementation of enable,
+ * disable, set_type and set_priority, written as byte stores.
+ *
+ * For (3) the reference below is the register expression from IDF's own replacement code, copied
+ * statement for statement with the file and line it came from, and using IDF's macros so the
+ * numbers are still IDF's. That is a transcription, and it is the weakest link in this file; it is
+ * marked as such at each site. Everything else calls IDF's code directly.
+ *
+ * Note also what the ROM situation means for the differential's *value* here: for enable, priority
+ * and trigger the comparison is against IDF's non-ROM path, which is the path IDF itself uses on
+ * TEE builds and the path its ROM patch restores. It is not against the ROM function a stock
+ * app_main would reach.
+ */
+
+/* IDF's clock and reset LL functions are shadowed by a wrapper macro referencing
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere, so that an unguarded call
+ * fails to compile. Nothing in this translation unit gates a clock, but the header chain reaches
+ * those declarations, so the name has to exist. Same reasoning as src/oracle/gpio_ref.c:18. */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+/* **First, and load-bearing.** soc/interrupt_reg.h tests CONFIG_ESP32P4_SELECTS_REV_LESS_V3 but
+ * does not include sdkconfig.h itself - it relies on the caller having done so, which in IDF's own
+ * build happens because CMake force-includes it. Include it *after* any header below and
+ * INTTHRESH_STANDARD comes out 1, the rev-3 answer, and this reference would be built against the
+ * mintthresh CSR that this silicon does not implement. That is not hypothetical: this file's first
+ * version had the includes in the obvious order and the #error below fired.
+ *
+ * build.zig now also passes `-include oracle_sdkconfig.h` to every reference translation unit, so
+ * this line is belt as well as braces. It stays because the ordering constraint is a property of
+ * IDF's headers, not of our build flags, and the next person to reorder these should see why. */
+#include "sdkconfig.h"
+#include "soc/soc.h"
+#include "soc/clic_reg.h"
+#include "soc/interrupt_reg.h"
+#include "hal/interrupt_clic_ll.h"
+#include "esp_private/interrupt_clic.h"
+
+/* Guard the whole point of this file: if the build ever stopped defining
+ * CONFIG_ESP32P4_SELECTS_REV_LESS_V3 (src/oracle/oracle_sdkconfig.h:25), interrupt_reg.h:28-40 would
+ * flip INTTHRESH_STANDARD to 1 and every threshold function below would silently switch from the
+ * memory-mapped register to the mintthresh CSR - which this die does not implement. The reference
+ * would then be comparing against a threshold mechanism that does not exist, and would agree with
+ * nothing. Fail the compile instead. */
+#if INTTHRESH_STANDARD
+#error "this die uses the memory-mapped CLIC threshold; INTTHRESH_STANDARD must be 0 here"
+#endif
+
+/* Report the numbers this reference was compiled with, so a run can never silently be against the
+ * wrong variant of the controller. */
+int oracle_intr_intthresh_standard(void)
+{
+ return INTTHRESH_STANDARD;
+}
+
+int oracle_intr_mintstatus_csr(void)
+{
+ return MINTSTATUS_CSR;
+}
+
+int oracle_intr_mtvt_csr(void)
+{
+ return MTVT_CSR;
+}
+
+int oracle_intr_nlbits(void)
+{
+ return NLBITS;
+}
+
+int oracle_intr_ext_offset(void)
+{
+ return CLIC_EXT_INTR_NUM_OFFSET;
+}
+
+unsigned oracle_intr_thresh_reg_addr(void)
+{
+ return (unsigned)CLIC_INT_THRESH_REG;
+}
+
+unsigned oracle_intr_ctrl_reg_addr(unsigned clic_id)
+{
+ return (unsigned)CLIC_INT_CTRL_REG(clic_id);
+}
+
+/* ---------------------------------------------------------------- the interrupt matrix */
+
+/* interrupt_clic_ll.h:35-48, with the `+ RV_EXTERNAL_INT_OFFSET` that riscv/interrupt_clic.c:26
+ * applies before calling it. Core 0 only: this image never releases core 1. */
+void oracle_intr_route(unsigned intr_src, unsigned line)
+{
+ interrupt_clic_ll_route(0, (int)intr_src, (int)line + RV_EXTERNAL_INT_OFFSET);
+}
+
+/* esp_system/port/cpu_start.c:185 - IDF's own way to detach a source, writing ETS_INVALID_INUM
+ * (0 on this chip, soc/esp32p4/include/soc/soc.h:251) with no offset added. */
+void oracle_intr_unroute(unsigned intr_src)
+{
+ interrupt_clic_ll_route(0, (int)intr_src, ETS_INVALID_INUM);
+}
+
+/* ---------------------------------------------------------------- per-line control */
+
+/* interrupt_clic_ll.h:99-102 via riscv/interrupt_clic.c:48-51. */
+void oracle_intr_set_vectored(unsigned line, int vectored)
+{
+ interrupt_clic_ll_set_vectored((int)line + RV_EXTERNAL_INT_OFFSET, vectored != 0);
+}
+
+/* interrupt_clic_ll.h:58-61 via riscv/interrupt_clic.c:30-33: 1 for edge, 0 for level. */
+int oracle_intr_get_type(unsigned line)
+{
+ return interrupt_clic_ll_get_type((int)line + RV_EXTERNAL_INT_OFFSET);
+}
+
+/* interrupt_clic_ll.h:71-75 via riscv/interrupt_clic.c:36-39. */
+int oracle_intr_get_priority(unsigned line)
+{
+ return interrupt_clic_ll_get_priority((int)line + RV_EXTERNAL_INT_OFFSET);
+}
+
+/* TRANSCRIBED, not called: the ROM owns esprv_intc_int_enable and there is no source for it.
+ * The store is esp_tee/subproject/main/include/clic/esp_tee_rv_utils.h:74, verbatim - a byte write
+ * of BYTE_CLIC_INT_IE to BYTE_CLIC_INT_IE_REG. Byte 1 of the control word holds nothing but IE, so
+ * this and a 32-bit read-modify-write of CLIC_INT_IE leave the same word behind; that equivalence
+ * is precisely what the differential is there to check rather than assert. */
+void oracle_intr_enable(unsigned line)
+{
+ const unsigned id = line + CLIC_EXT_INTR_NUM_OFFSET;
+ *(uint8_t volatile *)(BYTE_CLIC_INT_IE_REG(id)) = BYTE_CLIC_INT_IE;
+}
+
+/* TRANSCRIBED: esp_tee_rv_utils.h:88. */
+void oracle_intr_disable(unsigned line)
+{
+ const unsigned id = line + CLIC_EXT_INTR_NUM_OFFSET;
+ *(uint8_t volatile *)(BYTE_CLIC_INT_IE_REG(id)) = 0;
+}
+
+/* TRANSCRIBED: esp_rom/patches/esp_rom_clic.c:21, which is IDF's *replacement* for the ROM's
+ * broken esprv_intc_int_set_type on pre-v3 P4 silicon. A 32-bit REG_SET_FIELD on CLIC_INT_ATTR_TRIG,
+ * so unlike the TEE build's byte store it preserves SHV by read-modify-write rather than by the
+ * byte's other bits happening to be reloaded - same result, different mechanism. `type` is the raw
+ * two-bit encoding (0 level, 1 rising, 3 falling; clic_reg.h:84-88). */
+void oracle_intr_set_type(unsigned line, unsigned type)
+{
+ const unsigned id = line + CLIC_EXT_INTR_NUM_OFFSET;
+ REG_SET_FIELD(CLIC_INT_CTRL_REG(id), CLIC_INT_ATTR_TRIG, type);
+}
+
+/* TRANSCRIBED: esp_tee_rv_utils.h:112. Note the encoding - priority left-aligned into the top
+ * NLBITS of the byte with the low bits **zero**, which differs from the threshold's encoding
+ * below. */
+void oracle_intr_set_priority(unsigned line, unsigned priority)
+{
+ const unsigned id = line + CLIC_EXT_INTR_NUM_OFFSET;
+ *(uint8_t volatile *)(BYTE_CLIC_INT_CTL_REG(id)) = (uint8_t)(priority << BYTE_CLIC_INT_CTL_S);
+}
+
+/* esp_private/interrupt_clic.h, rv_utils_intr_edge_ack: writing 1 to IP is what *clears* an
+ * edge-triggered pending. Called directly - this one is a real IDF inline. */
+void oracle_intr_edge_ack(unsigned line)
+{
+ rv_utils_intr_edge_ack(line);
+}
+
+/* esp_private/interrupt_clic.h, rv_utils_intr_get_enabled_mask. */
+unsigned oracle_intr_enabled_mask(void)
+{
+ return rv_utils_intr_get_enabled_mask();
+}
+
+/* ---------------------------------------------------------------- the threshold */
+
+/* esp_private/interrupt_clic.h:153-156 -> :129-146. Called directly, so the reference includes
+ * IDF's own read-back-to-force-the-store and IDF's own NLBITS_TO_BYTE padding, and the harness
+ * compares against those rather than against a re-derivation of them. */
+void oracle_intr_set_threshold(unsigned level)
+{
+ rv_utils_restore_intlevel(level);
+}
+
+/* esp_private/interrupt_clic.h:44-57. Returns an absolute level 0..7. */
+unsigned oracle_intr_get_threshold(void)
+{
+ return rv_utils_get_interrupt_threshold();
+}
+
+/* ---------------------------------------------------------------- vector table */
+
+/* esp_private/interrupt_clic.h:63-66. MTVT is CSR 0x307. Writing it has no effect on any register
+ * the harness photographs, so this exists for the behavioural test rather than for the diff. */
+void oracle_intr_set_mtvt(unsigned mtvt)
+{
+ rv_utils_set_mtvt(mtvt);
+}
diff --git a/src/oracle/ledc_cases.zig b/src/oracle/ledc_cases.zig
new file mode 100644
index 0000000..5a39f83
--- /dev/null
+++ b/src/oracle/ledc_cases.zig
@@ -0,0 +1,513 @@
+//! LEDC's side of the differential test: every operation expressed as ESP-IDF's LL calls and as this
+//! project's HAL calls.
+//!
+//! **A register diff cannot prove that a commit happened.** `LEDC_PARA_UP_CHn` and
+//! `LEDC_TIMERn_PARA_UP` are write-to-trigger bits that the hardware clears again by itself, so the
+//! word that carried the commit reads back exactly as it did before, and the shadow registers the
+//! commit copies into are not addressable. Two snapshots therefore agree whether or not either
+//! implementation committed anything at all. Nothing in this file claims otherwise.
+//!
+//! What the diff *can* prove, and what these cases are shaped to prove:
+//!
+//! * The **staged values** match. Every case stages through the same fields IDF's LL stages, so a
+//! wrong shift, a wrong instance stride or a `write` where a `modify` was needed shows up in the
+//! staged word - which is the register the commit will read.
+//! * The commit **did not destroy the staging**. This is the real hazard of a commit bit that lives
+//! inside the word it commits: `LEDC_PARA_UP_CH0` is bit 4 of `LEDC_CH0_CONF0_REG`, so a commit
+//! implemented as `writeRaw(1 << 4)` would zero `TIMER_SEL`, `SIG_OUT_EN`, `IDLE_LV` and
+//! `OVF_NUM` on its way past. That failure is loud here: the staged word would differ.
+//! * `stage_without_commit` pins the distinction down. It stages a duty and stops, on both sides.
+//! It must pass, and it must pass for the same reason a committed case passes - which is the
+//! evidence that "passes" says nothing about the commit.
+//!
+//! `LEDC_CHn_DUTY_R_REG` is the one register that reflects the committed shadow rather than the
+//! staged value, and it is listed as volatile below rather than used as proof: it updates when the
+//! timer next overflows, so what it holds at snapshot time depends on where the counter happened to
+//! be. Proving the commit needs an oscilloscope, or the ovf-count interrupt, not a register read.
+//!
+//! Four windows, because LEDC's state is not in one place: the peripheral block, its gamma RAM
+//! aperture, the GPIO matrix (pin routing touches no LEDC register at all) and HP_SYS_CLKRST (where
+//! the P4 moved LEDC's clock mux). One suite each, since a `Peripheral` descriptor is one contiguous
+//! range of words.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+const ledc = hal.ledc;
+
+extern fn oracle_ledc_enable_function_clock(enable: c_int) void;
+extern fn oracle_ledc_set_clock_source(sel: c_uint) void;
+extern fn oracle_ledc_divisor(src_clk_freq: c_uint, freq_hz: c_int, precision: c_uint) c_uint;
+extern fn oracle_ledc_set_clock_divider(timer: c_uint, div: c_uint) void;
+extern fn oracle_ledc_set_duty_resolution(timer: c_uint, bits: c_uint) void;
+extern fn oracle_ledc_commit_timer(timer: c_uint) void;
+extern fn oracle_ledc_reset_timer(timer: c_uint) void;
+extern fn oracle_ledc_pause_timer(timer: c_uint) void;
+extern fn oracle_ledc_resume_timer(timer: c_uint) void;
+extern fn oracle_ledc_configure_timer(timer: c_uint, src_hz: c_uint, freq_hz: c_int, resolution: c_uint) void;
+extern fn oracle_ledc_bind_timer(channel: c_uint, timer: c_uint) void;
+extern fn oracle_ledc_set_hpoint(channel: c_uint, hpoint: c_uint) void;
+extern fn oracle_ledc_set_duty(channel: c_uint, duty: c_uint) void;
+extern fn oracle_ledc_set_output_enabled(channel: c_uint, enable: c_int) void;
+extern fn oracle_ledc_set_idle_level(channel: c_uint, level: c_uint) void;
+extern fn oracle_ledc_commit_channel(channel: c_uint) void;
+extern fn oracle_ledc_start(channel: c_uint) void;
+extern fn oracle_ledc_stop(channel: c_uint, idle_level: c_uint) void;
+extern fn oracle_ledc_configure_channel(
+ channel: c_uint,
+ timer: c_uint,
+ duty: c_uint,
+ hpoint: c_uint,
+ idle_level: c_uint,
+ output_enabled: c_int,
+) void;
+extern fn oracle_ledc_set_pin(pin: c_uint, channel: c_uint) void;
+
+/// The channel, timer and pad under test. Module-level variables because Zig has no closures and the
+/// harness stores plain `fn` pointers; the alternative, a comptime-specialised pair per channel,
+/// would compare code this project does not ship.
+///
+/// The suite is safe to run once per pair, the way GPIO's is run once per pin - `channels` and
+/// `timers` name the pairs worth using: instance 0, and the far end of each range, where a wrong
+/// `RegArray` stride would land outside the block.
+pub var channel: u32 = 0;
+pub var timer: u32 = 0;
+/// GPIO33 is a free pin on this board's JP1 header. GPIO20 is the LED, which the harness itself
+/// leaves blinking, and GPIO54 is the ESP32-C6's reset line and must never be driven.
+pub var pin: u8 = 33;
+
+pub const channels = [_]u32{ 0, 7 };
+pub const timers = [_]u32{ 0, 3 };
+
+/// 40 MHz XTAL: `ClockSource.xtal.hz()`, and what `setup` selects. Passed explicitly to both sides
+/// so the two arithmetics are compared on the same input rather than on each side's idea of the
+/// clock tree.
+const src_hz: u32 = ledc.xtal_hz;
+
+/// Bring LEDC up before the first case: its APB gate is off at power-on, so without this every
+/// snapshot would be the last value the bus latched and the harness would (correctly) skip the whole
+/// suite on the `clock` check.
+fn setup() void {
+ ledc.init(.xtal);
+}
+
+// ------------------------------------------------------------------- the peripheral block itself
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "ledc",
+ .base = @intCast(regs.LEDC_CH0_CONF0_REG),
+ // 96 words, 0x000-0x17f: eight channels (0x000-0x09f), four timers (0x0a0-0x0bf), the
+ // interrupt registers, the per-channel gamma *configuration* at 0x100-0x11f (the range
+ // count lives there, and `setDuty` writes it), the ETM enables, the timer compare and
+ // capture registers, and LEDC_CONF/LEDC_DATE at 0x170/0x174. Wide enough that every
+ // register any operation in this file touches is inside it except the gamma RAM aperture at
+ // 0x400, which has its own suite below.
+ //
+ // The reserved gaps (0x0d0-0x0ff, 0x130-0x13f, 0x160-0x16f) are read as well, deliberately:
+ // if a reserved word does not read back stably the diff will name the offset instead of
+ // hiding it.
+ .words = 96,
+ .volatile_words = &.{
+ // LEDC_CHn_DUTY_R: the committed duty shadow, reloaded on timer overflow.
+ (0x010 - 0x000) / 4, (0x024 - 0x000) / 4, (0x038 - 0x000) / 4, (0x04c - 0x000) / 4,
+ (0x060 - 0x000) / 4, (0x074 - 0x000) / 4, (0x088 - 0x000) / 4, (0x09c - 0x000) / 4,
+ // LEDC_TIMERn_VALUE: the live counters.
+ (0x0a4 - 0x000) / 4, (0x0ac - 0x000) / 4, (0x0b4 - 0x000) / 4, (0x0bc - 0x000) / 4,
+ // LEDC_INT_RAW and LEDC_INT_ST: overflow and fade-end bits latch while the timers run.
+ (0x0c0 - 0x000) / 4, (0x0c4 - 0x000) / 4,
+ // LEDC_TIMERn_CNT_CAP: captured counter values.
+ (0x150 - 0x000) / 4, (0x154 - 0x000) / 4, (0x158 - 0x000) / 4, (0x15c - 0x000) / 4,
+ },
+ // REG_LEDC_APB_CLK_EN, bit 0 of SOC_CLK_CTRL3 (ledc_ll.h:135). Its reset value is 0, so this
+ // check is not a formality for LEDC: it is the difference between a snapshot and a memory of
+ // one.
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL3_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_LEDC_APB_CLK_EN_S),
+ },
+ // The peripheral reset, REG_RST_EN_LEDC, bit 29 of HP_RST_EN1 (ledc_ll.h:150,
+ // hp_sys_clkrst_reg.h:3497-3503). Sound here where a configure-restore would not be: this
+ // block has write-to-trigger fields (both PARA_UPs, OVF_CNT_RESET) whose reset value is only
+ // defined by the reset, and `LEDC_TIMERn_RST` is one of the fields whose reset value is 1 -
+ // so "write zeros everywhere" would not be a restore at all. Measured safe on this board:
+ // pulsing it for 1 ms left the console untouched and returned LEDC_CH0_CONF0 to 0.
+ .restore = .{ .reset_bit = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_HP_RST_EN1_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_RST_EN_LEDC_S),
+ } },
+ },
+ .setup = setup,
+ .cases = &.{
+ // Timer: the whole sequence, at four target frequencies across three duty resolutions. Each
+ // side computes its own divider - IDF's `ledc_calculate_divisor`, ours `hal.ledc.divisor` -
+ // so a mismatch in the fixed-point arithmetic lands in LEDC_TIMERn_CONF[22:5] and is caught
+ // here rather than being argued about. The four dividers are 1250, 500, 2000 and 2083.
+ .{ .name = "configure_timer_1kHz_13bit", .arg = 1_000, .idf = idfTimer1k13, .ours = ourTimer1k13 },
+ .{ .name = "configure_timer_20kHz_10bit", .arg = 20_000, .idf = idfTimer20k10, .ours = ourTimer20k10 },
+ .{ .name = "configure_timer_5kHz_10bit", .arg = 5_000, .idf = idfTimer5k10, .ours = ourTimer5k10 },
+ .{ .name = "configure_timer_300Hz_14bit", .arg = 300, .idf = idfTimer300_14, .ours = ourTimer300_14 },
+ // The divider store and the arithmetic behind it, without the resolution/resume/reset tail.
+ .{ .name = "clock_divider_only", .arg = 1_250, .idf = idfDivider, .ours = ourDivider },
+ .{ .name = "duty_resolution_only", .arg = 13, .idf = idfResolution, .ours = ourResolution },
+ .{ .name = "timer_pause", .idf = idfPause, .ours = ourPause },
+ .{ .name = "timer_resume", .idf = idfResume, .ours = ourResume },
+ .{ .name = "timer_reset", .idf = idfTimerReset, .ours = ourTimerReset },
+ // Channel.
+ .{ .name = "bind_timer", .idf = idfBind, .ours = ourBind },
+ .{ .name = "set_hpoint", .arg = 0x400, .idf = idfHpoint, .ours = ourHpoint },
+ .{ .name = "set_duty", .arg = 0x1000, .idf = idfDuty4096, .ours = ourDuty4096 },
+ .{ .name = "set_duty", .arg = 0, .idf = idfDuty0, .ours = ourDuty0 },
+ // Staged and left uncommitted, on both sides. Passes for the same reason the committed cases
+ // pass, which is the point: the commit is not in the picture the harness takes.
+ .{ .name = "stage_without_commit", .arg = 0x555, .idf = idfStageOnly, .ours = ourStageOnly },
+ .{ .name = "channel_start", .idf = idfStart, .ours = ourStart },
+ .{ .name = "channel_stop_idle_low", .arg = 0, .idf = idfStopLow, .ours = ourStopLow },
+ .{ .name = "channel_stop_idle_high", .arg = 1, .idf = idfStopHigh, .ours = ourStopHigh },
+ .{ .name = "configure_channel", .arg = 0x800, .idf = idfConfigureChannel, .ours = ourConfigureChannel },
+ .{ .name = "full_rf_config_25MHz_1bit", .arg = 25, .idf = idfFullRf, .ours = ourFullRf },
+ },
+};
+
+// -------------------------------------------------------------------------- the gamma RAM window
+
+/// Zero the whole gamma RAM aperture and pulse the peripheral reset.
+///
+/// The zeroing is the load-bearing half. Gamma RAM is RAM: the peripheral reset does *not* clear it,
+/// so without this the second run would inherit whatever the first run wrote, and an implementation
+/// that wrote no gamma entry at all would compare equal to one that did - the self-consistent test
+/// that proves nothing. All 128 words rather than the channel under test's 16, so that the state the
+/// two runs start from does not depend on which cases ran before.
+fn restoreGamma() void {
+ var w: u32 = 0;
+ while (w < 128) : (w += 1) {
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.LEDC_CH0_GAMMA_RANGE0_REG)) + 4 * w).writeRaw(0);
+ }
+ hal.clkrst.resetPeripheral(.ledc);
+}
+
+/// The gamma RAM aperture, 0x400-0x5ff: sixteen entries for each of the eight channels.
+///
+/// It has its own suite because it is not contiguous with the register block - between them lies a
+/// 0x288-byte hole that nothing documents, and reading unmapped peripheral space to get from one to
+/// the other is not a risk worth taking on the only board.
+///
+/// What it covers: on the P4 a constant duty is a degenerate one-step fade, because
+/// `DUTY_NUM`/`DUTY_CYCLE`/`DUTY_SCALE`/`DUTY_INC` moved out of `LEDC_CHn_CONF1_REG` into this RAM.
+/// `setDuty` writes entry 0 accordingly (ledc.c:263-280), and this is the window that sees it.
+pub const gamma_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "ledc_gamma",
+ .base = @intCast(regs.LEDC_CH0_GAMMA_RANGE0_REG),
+ .words = 128,
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL3_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_LEDC_APB_CLK_EN_S),
+ },
+ .restore = .{ .configure = restoreGamma },
+ },
+ .setup = setup,
+ .cases = &.{
+ .{ .name = "set_duty_writes_entry0", .arg = 0x1000, .idf = idfDuty4096, .ours = ourDuty4096 },
+ .{ .name = "set_duty_writes_entry0", .arg = 0, .idf = idfDuty0, .ours = ourDuty0 },
+ .{ .name = "configure_channel_writes_entry0", .arg = 0x800, .idf = idfConfigureChannel, .ours = ourConfigureChannel },
+ },
+};
+
+// ------------------------------------------------------------------------------- the GPIO window
+
+/// The pad back to a known state: driver off, IO MUX word zeroed, matrix pointing at plain GPIO.
+/// The same restore GPIO's own suite uses, for the same reason - there is no reset bit for GPIO and
+/// the pads are the board's wiring.
+fn restorePad() void {
+ hal.gpio.outputDisable(pin);
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0)) + 4 * @as(u32, pin)).writeRaw(0);
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.GPIO_FUNC0_OUT_SEL_CFG_REG)) + 4 * @as(u32, pin))
+ .writeRaw(hal.gpio.matrix_gpio_signal);
+ hal.gpio.setLow(pin);
+}
+
+/// Pin routing touches no LEDC register: the peripheral has no pad of its own, and `attachPin` is
+/// entirely a GPIO matrix operation. So it is compared in the GPIO window, where its effect is - and
+/// what is actually under test here is the signal index, `LEDC_LS_SIG_OUT_PAD_OUT0_IDX + channel`,
+/// which is the one piece of arithmetic in the routing path.
+pub const routing_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "ledc_pin",
+ .base = @intCast(regs.GPIO_OUT_REG - 4), // GPIO_BT_SELECT_REG sits at +0x00
+ .words = 400,
+ .volatile_words = &.{
+ (0x03c - 0x000) / 4, // GPIO_IN - the outside world, which moves
+ (0x040 - 0x000) / 4, // GPIO_IN1
+ },
+ .restore = .{ .configure = restorePad },
+ },
+ .cases = &.{
+ .{ .name = "attach_pin", .idf = idfAttachPin, .ours = ourAttachPin },
+ .{ .name = "attach_pin_channel7", .arg = 7, .idf = idfAttachPin7, .ours = ourAttachPin7 },
+ },
+};
+
+// -------------------------------------------------------------------------- the HP_SYS_CLKRST word
+
+/// LEDC's clock mux and function-clock gate back to what `setup` establishes. Only LEDC's own fields
+/// are written: PERI_CLK_CTRL22 also holds RMT's, and this is a live board.
+
+/// 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 restoreClk() void {
+ oracle_ledc_set_clock_source(0); // 0 = XTAL, the value idfSrcXtal uses
+ oracle_ledc_enable_function_clock(1);
+}
+
+/// One word: `HP_SYS_CLKRST_PERI_CLK_CTRL22_REG`, which on the P4 holds LEDC's clock source select
+/// and its function-clock gate (ledc_ll.h:179, :241). This is where the LEDC clock source lives on
+/// this die - not in `LEDC_CONF_REG.APB_CLK_SEL`, which the register map still documents with a
+/// *different* encoding and which IDF's P4 LL never writes. A HAL that wrote the in-block register
+/// would pass every case in the `ledc` suite above and produce no PWM at all; this window is what
+/// makes that visible.
+///
+/// The case order matters: the last case must leave the function clock on and the source at XTAL,
+/// because the harness restores *before* each case and not after the last one.
+pub const clock_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "ledc_clk",
+ .base = @intCast(regs.HP_SYS_CLKRST_PERI_CLK_CTRL22_REG),
+ .words = 1,
+ .restore = .{ .configure = restoreClk },
+ },
+ .setup = setup,
+ .cases = &.{
+ .{ .name = "clock_source_rc_fast", .arg = 1, .idf = idfSrcRcFast, .ours = ourSrcRcFast },
+ .{ .name = "clock_source_pll_div", .arg = 2, .idf = idfSrcPllDiv, .ours = ourSrcPllDiv },
+ .{ .name = "clock_source_xtal", .arg = 0, .idf = idfSrcXtal, .ours = ourSrcXtal },
+ .{ .name = "function_clock_off", .arg = 0, .idf = idfFuncClkOff, .ours = ourFuncClkOff },
+ .{ .name = "function_clock_on", .arg = 1, .idf = idfFuncClkOn, .ours = ourFuncClkOn },
+ },
+};
+
+/// All four windows, in the order they should run: the block first, because a failure there explains
+/// failures in the other three.
+pub const suites = [_]types.Suite{ suite, gamma_suite, routing_suite, clock_suite };
+
+// --------------------------------------------------------------------------------- the case pairs
+//
+// `catch {}` rather than `catch unreachable` on the `configureTimer` calls: all four divider values
+// are inside the field's range (checked on the host against IDF's own expression), so the error path
+// is dead - but if this HAL's validity check ever disagreed with IDF's, doing nothing leaves the
+// timer unconfigured and the harness reports a diff, where `unreachable` would be undefined
+// behaviour in a ReleaseSmall build and would report nothing.
+
+fn idfTimer1k13() void {
+ oracle_ledc_configure_timer(timer, src_hz, 1_000, 13);
+}
+fn ourTimer1k13() void {
+ ledc.configureTimer(timer, .{ .src_hz = src_hz, .freq_hz = 1_000, .resolution = 13 }) catch {};
+}
+fn idfTimer20k10() void {
+ oracle_ledc_configure_timer(timer, src_hz, 20_000, 10);
+}
+fn ourTimer20k10() void {
+ ledc.configureTimer(timer, .{ .src_hz = src_hz, .freq_hz = 20_000, .resolution = 10 }) catch {};
+}
+fn idfTimer5k10() void {
+ oracle_ledc_configure_timer(timer, src_hz, 5_000, 10);
+}
+fn ourTimer5k10() void {
+ ledc.configureTimer(timer, .{ .src_hz = src_hz, .freq_hz = 5_000, .resolution = 10 }) catch {};
+}
+fn idfTimer300_14() void {
+ oracle_ledc_configure_timer(timer, src_hz, 300, 14);
+}
+fn ourTimer300_14() void {
+ ledc.configureTimer(timer, .{ .src_hz = src_hz, .freq_hz = 300, .resolution = 14 }) catch {};
+}
+
+// Each side computes the divider with its own arithmetic and stores it with its own code: 40 MHz,
+// 1 kHz, 13 bits, which is 1250 = 0x4E2 = 4.8828 in Q10.8.
+fn idfDivider() void {
+ oracle_ledc_set_clock_divider(timer, oracle_ledc_divisor(src_hz, 1_000, 1 << 13));
+ oracle_ledc_commit_timer(timer);
+}
+fn ourDivider() void {
+ ledc.setClockDivider(timer, ledc.divisor(src_hz, 1_000, 13));
+ ledc.commitTimer(timer);
+}
+
+fn idfResolution() void {
+ oracle_ledc_set_duty_resolution(timer, 13);
+ oracle_ledc_commit_timer(timer);
+}
+fn ourResolution() void {
+ ledc.setDutyResolution(timer, 13);
+ ledc.commitTimer(timer);
+}
+
+fn idfPause() void {
+ oracle_ledc_pause_timer(timer);
+}
+fn ourPause() void {
+ ledc.pauseTimer(timer);
+}
+fn idfResume() void {
+ oracle_ledc_resume_timer(timer);
+}
+fn ourResume() void {
+ ledc.resumeTimer(timer);
+}
+fn idfTimerReset() void {
+ oracle_ledc_reset_timer(timer);
+}
+fn ourTimerReset() void {
+ ledc.resetTimer(timer);
+}
+
+fn idfBind() void {
+ oracle_ledc_bind_timer(channel, timer);
+ oracle_ledc_commit_channel(channel);
+}
+fn ourBind() void {
+ ledc.bindTimer(channel, timer);
+ ledc.commitChannel(channel);
+}
+
+fn idfHpoint() void {
+ oracle_ledc_set_hpoint(channel, 0x400);
+ oracle_ledc_commit_channel(channel);
+}
+fn ourHpoint() void {
+ ledc.setHpoint(channel, 0x400);
+ ledc.commitChannel(channel);
+}
+
+fn idfDuty4096() void {
+ oracle_ledc_set_duty(channel, 0x1000);
+ oracle_ledc_commit_channel(channel);
+}
+fn ourDuty4096() void {
+ ledc.setDuty(channel, 0x1000);
+ ledc.commitChannel(channel);
+}
+fn idfDuty0() void {
+ oracle_ledc_set_duty(channel, 0);
+ oracle_ledc_commit_channel(channel);
+}
+fn ourDuty0() void {
+ ledc.setDuty(channel, 0);
+ ledc.commitChannel(channel);
+}
+
+// No commit on either side. The staged duty and gamma entry must still match.
+fn idfStageOnly() void {
+ oracle_ledc_set_duty(channel, 0x555);
+}
+fn ourStageOnly() void {
+ ledc.setDuty(channel, 0x555);
+}
+
+fn idfStart() void {
+ oracle_ledc_start(channel);
+}
+fn ourStart() void {
+ ledc.start(channel);
+}
+fn idfStopLow() void {
+ oracle_ledc_stop(channel, 0);
+}
+fn ourStopLow() void {
+ ledc.stop(channel, 0);
+}
+fn idfStopHigh() void {
+ oracle_ledc_stop(channel, 1);
+}
+fn ourStopHigh() void {
+ ledc.stop(channel, 1);
+}
+
+fn idfConfigureChannel() void {
+ oracle_ledc_configure_channel(channel, timer, 0x800, 0x200, 1, 1);
+}
+fn ourConfigureChannel() void {
+ ledc.configureChannel(channel, .{
+ .timer = timer,
+ .duty = 0x800,
+ .hpoint = 0x200,
+ .idle_level = 1,
+ .output_enabled = true,
+ });
+}
+
+fn idfAttachPin() void {
+ oracle_ledc_set_pin(pin, channel);
+}
+fn ourAttachPin() void {
+ ledc.attachPin(channel, pin);
+}
+// Channel 7 explicitly, because the signal index is arithmetic on the channel number and 0 is the
+// one value that cannot catch an off-by-one in it.
+fn idfAttachPin7() void {
+ oracle_ledc_set_pin(pin, 7);
+}
+fn ourAttachPin7() void {
+ ledc.attachPin(7, pin);
+}
+
+/// The report's RF configuration, end to end: 1-bit resolution at 25 MHz, duty 1, hpoint 0, on
+/// channel 0 / timer 0. Reproduced from the ESP-IDF firmware in 02-esp32p4-m3-radio/main/main.c:77-94.
+///
+/// This case exists because the two implementations disagree *on the die* for exactly this
+/// configuration and nothing smaller: the IDF firmware's carrier toggles GPIO20 at 25 MHz (proven by
+/// its own ADC witness catching both rails), and this project's HAL leaves the pad static, while
+/// every individual register operation compares equal. So the difference is in the composition, and
+/// comparing the whole block after each full bring-up is the only thing that can localise it.
+/// Note the source: 80 MHz, not this suite's default `src_hz` (which is XTAL at 40 MHz). At 40 MHz a
+/// 1-bit 25 MHz target needs divider 205, below the legal minimum of 256, and the two sides then
+/// disagree for a reason that has nothing to do with the RF experiment: this HAL rejects it with
+/// DividerOutOfRange while ESP-IDF's *LL* programs it anyway, because IDF's range check lives one
+/// layer up in ledc.c rather than in the LL. Worth knowing - it means an IDF LL caller can silently
+/// program an illegal divider - but it is not what this case is for.
+fn idfFullRf() void {
+ oracle_ledc_configure_timer(0, ledc.pll_div_hz, 25_000_000, 1);
+ oracle_ledc_configure_channel(0, 0, 1, 0, 0, 1);
+}
+fn ourFullRf() void {
+ ledc.configureTimer(0, .{ .src_hz = ledc.pll_div_hz, .freq_hz = 25_000_000, .resolution = 1 }) catch return;
+ ledc.configureChannel(0, .{ .timer = 0, .duty = 1, .hpoint = 0, .idle_level = 0 });
+}
+
+fn idfSrcXtal() void {
+ oracle_ledc_set_clock_source(0);
+}
+fn ourSrcXtal() void {
+ ledc.setClockSource(.xtal);
+}
+fn idfSrcRcFast() void {
+ oracle_ledc_set_clock_source(1);
+}
+fn ourSrcRcFast() void {
+ ledc.setClockSource(.rc_fast);
+}
+fn idfSrcPllDiv() void {
+ oracle_ledc_set_clock_source(2);
+}
+fn ourSrcPllDiv() void {
+ ledc.setClockSource(.pll_div);
+}
+fn idfFuncClkOff() void {
+ oracle_ledc_enable_function_clock(0);
+}
+fn ourFuncClkOff() void {
+ ledc.setFunctionClockEnabled(false);
+}
+fn idfFuncClkOn() void {
+ oracle_ledc_enable_function_clock(1);
+}
+fn ourFuncClkOn() void {
+ ledc.setFunctionClockEnabled(true);
+}
+
diff --git a/src/oracle/ledc_ref.c b/src/oracle/ledc_ref.c
new file mode 100644
index 0000000..6b1403a
--- /dev/null
+++ b/src/oracle/ledc_ref.c
@@ -0,0 +1,210 @@
+/* LEDC's reference implementation: ESP-IDF's own LL, given external linkage.
+ *
+ * There is no logic here except where a comment says otherwise, and there is exactly one such
+ * place - `oracle_ledc_divisor` - because the divider arithmetic lives in a `static inline` inside
+ * `esp_driver_ledc/src/ledc.c` and is therefore unreachable from a header. It is transcribed
+ * character for character, with the line number, so that the on-die comparison covers the
+ * arithmetic and not only the store that follows it.
+ */
+
+/* IDF's clock and reset LL functions are shadowed by a wrapper macro that references
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere; its purpose is to make an
+ * unguarded call fail to compile, because the only legal caller holds a spinlock. There is no
+ * FreeRTOS here and core 1 is held in reset at power-on, so declaring the name is exactly as safe
+ * as the spinlock would be - and it is what IDF's own bootloader does. */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+/* `ledc_ll_set_slow_clk_sel` and `ledc_ll_get_slow_clk_sel` call `abort()` in the default arm of
+ * their switch (ledc_ll.h:238, :273). Freestanding, nothing declares it; the arms below are all
+ * reached with constants, so the call folds away and no definition is needed. */
+void abort(void);
+
+#include <stdint.h>
+
+#include "hal/ledc_ll.h"
+#include "hal/gpio_ll.h"
+#include "soc/gpio_struct.h"
+#include "soc/gpio_sig_map.h"
+
+/* P4 has one speed mode: low. `ledc_ll.h` still takes the parameter because the LL is shared with
+ * parts that have two. */
+#define MODE LEDC_LOW_SPEED_MODE
+
+/* ---------------------------------------------------------------- clocks, outside the LEDC block */
+
+void oracle_ledc_enable_bus_clock(int enable)
+{
+ ledc_ll_enable_bus_clock(enable != 0);
+}
+
+void oracle_ledc_enable_function_clock(int enable)
+{
+ ledc_ll_enable_clock(LEDC_LL_GET_HW(), enable != 0);
+}
+
+/* `sel` is this project's `ClockSource` enum, which is HP_SYS_CLKRST's own encoding: 0 XTAL,
+ * 1 RC_FAST, 2 PLL_DIV. Split into three constant calls so that IDF's switch folds and its
+ * `abort()` arm never reaches the linker. */
+void oracle_ledc_set_clock_source(unsigned sel)
+{
+ switch (sel) {
+ case 0:
+ ledc_ll_set_slow_clk_sel(LEDC_LL_GET_HW(), LEDC_SLOW_CLK_XTAL);
+ break;
+ case 1:
+ ledc_ll_set_slow_clk_sel(LEDC_LL_GET_HW(), LEDC_SLOW_CLK_RC_FAST);
+ break;
+ case 2:
+ ledc_ll_set_slow_clk_sel(LEDC_LL_GET_HW(), LEDC_SLOW_CLK_PLL_DIV);
+ break;
+ default:
+ break;
+ }
+}
+
+/* ---------------------------------------------------------------------------- divider arithmetic */
+
+/* Verbatim from esp_driver_ledc/src/ledc.c:468-497 (v6.0.2), which is `static inline` in a .c file
+ * and so cannot be called. The 32-bit wrap of `freq_hz * precision` and the truncation of the
+ * 64-bit quotient into `uint32_t` are IDF's, and are the whole reason this exists: they are what
+ * src/hal/ledc.zig's `divisor` has to reproduce. */
+uint32_t oracle_ledc_divisor(uint32_t src_clk_freq, int freq_hz, uint32_t precision)
+{
+ return (((uint64_t) src_clk_freq << LEDC_LL_FRACTIONAL_BITS) + freq_hz * precision / 2)
+ / (freq_hz * precision);
+}
+
+/* ------------------------------------------------------------------------------------- timers */
+
+void oracle_ledc_set_clock_divider(unsigned timer, uint32_t div)
+{
+ ledc_ll_set_clock_divider(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer, div);
+}
+
+void oracle_ledc_set_duty_resolution(unsigned timer, uint32_t bits)
+{
+ ledc_ll_set_duty_resolution(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer, bits);
+}
+
+void oracle_ledc_commit_timer(unsigned timer)
+{
+ ledc_ll_ls_timer_update(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+}
+
+void oracle_ledc_reset_timer(unsigned timer)
+{
+ ledc_ll_timer_rst(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+}
+
+void oracle_ledc_pause_timer(unsigned timer)
+{
+ ledc_ll_timer_pause(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+}
+
+void oracle_ledc_resume_timer(unsigned timer)
+{
+ ledc_ll_timer_resume(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+}
+
+/* `ledc_set_timer_params` (ledc.c:244-261) followed by the resume/reset pair `ledc_timer_config`
+ * does on success (ledc.c:816-818). The clock-source step of `ledc_set_timer_params` is absent on
+ * purpose: on the P4 there is no timer-specific mux (SOC_LEDC_HAS_TIMER_SPECIFIC_MUX is unset), so
+ * that step compiles out of IDF too. */
+void oracle_ledc_configure_timer(unsigned timer, uint32_t src_hz, int freq_hz, uint32_t resolution)
+{
+ uint32_t div = oracle_ledc_divisor(src_hz, freq_hz, 1u << resolution);
+ ledc_ll_set_clock_divider(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer, div);
+ ledc_ll_set_duty_resolution(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer, resolution);
+ ledc_ll_ls_timer_update(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+ ledc_ll_timer_resume(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+ ledc_ll_timer_rst(LEDC_LL_GET_HW(), MODE, (ledc_timer_t)timer);
+}
+
+/* ------------------------------------------------------------------------------------ channels */
+
+void oracle_ledc_bind_timer(unsigned channel, unsigned timer)
+{
+ ledc_ll_bind_channel_timer(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, (ledc_timer_t)timer);
+}
+
+void oracle_ledc_set_hpoint(unsigned channel, uint32_t hpoint)
+{
+ ledc_ll_set_hpoint(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, hpoint);
+}
+
+/* `ledc_duty_config` (ledc.c:263-280) with `hpoint_val` left alone: the duty integer part, then the
+ * degenerate one-step fade in gamma RAM entry 0 that a constant duty needs on this die, then the
+ * range count. `ledc_hal_clear_left_off_fade_param` is deliberately not called - it zeroes ranges
+ * 1..15, which only matters once real fades are in scope. */
+void oracle_ledc_set_duty(unsigned channel, uint32_t duty)
+{
+ ledc_ll_set_duty_int_part(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, duty);
+ ledc_ll_set_fade_param_range(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, 0, 1, 1, 0, 1);
+ ledc_ll_set_range_number(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, 1);
+}
+
+void oracle_ledc_set_output_enabled(unsigned channel, int enable)
+{
+ ledc_ll_set_sig_out_en(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, enable != 0);
+}
+
+void oracle_ledc_set_idle_level(unsigned channel, uint32_t level)
+{
+ ledc_ll_set_idle_level(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, level);
+}
+
+void oracle_ledc_start_fade(unsigned channel)
+{
+ ledc_ll_set_duty_start(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+}
+
+void oracle_ledc_commit_channel(unsigned channel)
+{
+ ledc_ll_ls_channel_update(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+}
+
+/* `_ledc_update_duty`, ledc.c:1021-1026. */
+void oracle_ledc_start(unsigned channel)
+{
+ ledc_ll_set_sig_out_en(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, true);
+ ledc_ll_set_duty_start(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+ ledc_ll_ls_channel_update(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+}
+
+/* `ledc_stop`, ledc.c:1039-1050: idle level staged before the output is disabled, one commit. */
+void oracle_ledc_stop(unsigned channel, uint32_t idle_level)
+{
+ ledc_ll_set_idle_level(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, idle_level);
+ ledc_ll_set_sig_out_en(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, false);
+ ledc_ll_ls_channel_update(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+}
+
+/* The register half of `ledc_channel_config` (ledc.c:869-1019): stage timer, hpoint, duty, idle
+ * level and output enable, hand the duty over, commit once. */
+void oracle_ledc_configure_channel(unsigned channel, unsigned timer, uint32_t duty, uint32_t hpoint,
+ uint32_t idle_level, int output_enabled)
+{
+ ledc_ll_bind_channel_timer(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, (ledc_timer_t)timer);
+ ledc_ll_set_hpoint(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, hpoint);
+ ledc_ll_set_duty_int_part(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, duty);
+ ledc_ll_set_fade_param_range(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, 0, 1, 1, 0, 1);
+ ledc_ll_set_range_number(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, 1);
+ ledc_ll_set_idle_level(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, idle_level);
+ ledc_ll_set_sig_out_en(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel, output_enabled != 0);
+ ledc_ll_set_duty_start(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+ ledc_ll_ls_channel_update(LEDC_LL_GET_HW(), MODE, (ledc_channel_t)channel);
+}
+
+/* ---------------------------------------------------------------------------------- pin routing */
+
+/* The hardware effect of `ledc_set_pin` (ledc.c:823-836). `gpio_matrix_output` is
+ * `gpio_hal_matrix_out` (gpio_hal.c:60-69): pad function, matrix source, then the output-enable
+ * control last "to avoid undesired level change". The signal index is
+ * `ledc_periph_signal[0].sig_out0_idx + channel`, and that field is initialised to
+ * `LEDC_LS_SIG_OUT_PAD_OUT0_IDX` in esp_hal_ledc/esp32p4/ledc_periph.c:14-18. */
+void oracle_ledc_set_pin(unsigned pin, unsigned channel)
+{
+ gpio_ll_func_sel(&GPIO, pin, PIN_FUNC_GPIO);
+ gpio_ll_set_output_signal_matrix_source(&GPIO, pin, LEDC_LS_SIG_OUT_PAD_OUT0_IDX + channel, false);
+ gpio_ll_set_output_enable_ctrl(&GPIO, pin, true, false);
+}
diff --git a/src/oracle/oracle_sdkconfig.h b/src/oracle/oracle_sdkconfig.h
new file mode 100644
index 0000000..c74fb50
--- /dev/null
+++ b/src/oracle/oracle_sdkconfig.h
@@ -0,0 +1,43 @@
+/* The Kconfig surface ESP-IDF's LL headers are compiled against when they are used as the
+ * differential reference. Deliberately minimal and deliberately *ours*.
+ *
+ * An earlier attempt borrowed sdkconfig.h from an unrelated ESP-IDF project in this workspace. That
+ * is a trap with a measurable cost: the borrowed file sets CONFIG_HAL_GPIO_USE_ROM_IMPL=1, which
+ * makes gpio_ll_set_level() call rom_gpio_set_output_level() and write no GPIO register at all -
+ * so the very first differential would have compared this HAL against the mask ROM rather than
+ * against IDF's register sequence. Recompiling the same LL headers against a different sdkconfig
+ * changes the emitted .text of six of nine tier-1/2 peripherals, so this file is part of the
+ * experiment's definition, not incidental.
+ *
+ * Anything not defined here is simply absent, which for IDF's `#if` tests means zero. That is the
+ * behaviour we want: the register path, with nothing optional switched on.
+ */
+#pragma once
+
+/* Target selection. Everything under components/soc and components/hal keys off this. */
+#define CONFIG_IDF_TARGET_ESP32P4 1
+#define CONFIG_IDF_TARGET "esp32p4"
+
+/* Pre-v3 silicon: this die is rev v1.3. The same condition selects register/hw_ver1 in IDF's own
+ * build (soc/CMakeLists.txt:37-41) and esp32p4.rom.ld rather than esp32p4.rom.eco5.ld - 237 of 452
+ * common ROM symbols have different addresses between those two files, so the pairing is not
+ * cosmetic. build.zig asserts the register module was built from hw_ver1 to match. */
+#define CONFIG_ESP32P4_SELECTS_REV_LESS_V3 1
+#define CONFIG_ESP32P4_REV_MIN_FULL 100
+#define CONFIG_ESP32P4_REV_MAX_FULL 199
+
+/* 40 MHz crystal, as fitted. Reaches the HAL through HAL_CONFIG_XTAL_HINT_FREQ_MHZ. */
+#define CONFIG_XTAL_FREQ 40
+
+/* Assertions off, and this one is a real choice rather than tidiness: at level 2 HAL_ASSERT expands
+ * to __assert_func (a libc symbol this image does not have), and at 0 it becomes
+ * __builtin_unreachable(), which lets clang delete the argument-checking branches. The reference
+ * implementation should be the code IDF ships in a release build, and a harness that wants to test
+ * argument validation must not rely on a branch the compiler is entitled to remove. */
+#define CONFIG_HAL_DEFAULT_ASSERTION_LEVEL 0
+
+/* NOT defined, on purpose:
+ * CONFIG_HAL_GPIO_USE_ROM_IMPL - would route gpio_ll_set_level through the mask ROM (see above).
+ * CONFIG_IDF_ENV_FPGA - would change efuse and clock behaviour to the FPGA model.
+ * CONFIG_PM_*, CONFIG_FREERTOS_* - no power management and no OS in this image.
+ */
diff --git a/src/oracle/runtime_ref.c b/src/oracle/runtime_ref.c
new file mode 100644
index 0000000..659661b
--- /dev/null
+++ b/src/oracle/runtime_ref.c
@@ -0,0 +1,19 @@
+/* The few libc symbols ESP-IDF's LL code reaches for, supplied so the reference can link into a
+ * freestanding image.
+ *
+ * There is exactly one so far, and it is reached by design rather than by accident:
+ * `_uart_ll_set_baudrate` (uart_ll.h:532-535) calls `abort()` when handed an LP_UART instance,
+ * because that path needs `lp_uart_ll_set_baudrate` instead. The differential harness only ever
+ * passes HP UART instances, so this is unreachable in practice - but the linker does not know that,
+ * and a missing `abort` fails the build with a symbol name that explains nothing about why.
+ *
+ * Spinning rather than resetting is deliberate: if a reference implementation ever does call this,
+ * the board stops with its last console line intact, which is the difference between a diagnosable
+ * failure and a reboot loop.
+ */
+
+__attribute__((noreturn)) void abort(void)
+{
+ for (;;) {
+ }
+}
diff --git a/src/oracle/sdmmc_cases.zig b/src/oracle/sdmmc_cases.zig
new file mode 100644
index 0000000..4b53aed
--- /dev/null
+++ b/src/oracle/sdmmc_cases.zig
@@ -0,0 +1,567 @@
+//! SDMMC's side of the differential test.
+//!
+//! Two windows, because this peripheral's state is not contiguous. The controller's own register
+//! block is at 0x50083000; its *host* clock generator - source mux, two-stage divider, sampling
+//! phase - is not in it at all, but in HP_SYS_CLKRST, where the P4 moved it. That is the same
+//! split I2C has (`i2c.clock_suite`), and for the same reason: a driver that programmed every
+//! register inside the block perfectly and the divider not at all would run the bus at the wrong
+//! frequency and pass every case in the first suite.
+//!
+//! **No case sends a command to the card.** The five `cmd_word_*` cases write the command register
+//! with `start_command` (bit 31) cleared, which is what makes them safe: bit 31 is the launch, and
+//! a word without it is inert. The C6 is in reset for the whole of a differ run - GPIO54 is never
+//! released - so a real CMD52 would sit out its response timeout and prove nothing. What is being
+//! compared is the encoding, and the encoding is entirely visible in the staged word.
+//!
+//! **Restore is the peripheral's own reset**, LP_AON_CLKRST bit 28, which is legitimate here and
+//! not merely convenient: this block is full of self-clearing and write-1-to-clear bits (the three
+//! reset bits in CTRL, every bit of RINTSTS, the IDMAC's software reset), and writing a snapshot
+//! back would trigger a reset rather than undo one. Nothing in this file restores through the code
+//! under test; the only Zig the harness runs between the two halves is `mmio`.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+extern fn oracle_sdmmc_bus_clock(enable: c_int) void;
+extern fn oracle_sdmmc_reset_register() void;
+extern fn oracle_sdmmc_set_host_clock_div(div: c_uint) void;
+extern fn oracle_sdmmc_select_clk_source_pll160m() void;
+extern fn oracle_sdmmc_init_phase_delay() void;
+extern fn oracle_sdmmc_set_card_clock_div(slot: c_uint, div: c_uint) void;
+extern fn oracle_sdmmc_enable_card_clock(slot: c_uint, enable: c_int) void;
+extern fn oracle_sdmmc_enable_card_clock_low_power(slot: c_uint, enable: c_int) void;
+extern fn oracle_sdmmc_reset_controller() void;
+extern fn oracle_sdmmc_reset_dma() void;
+extern fn oracle_sdmmc_reset_fifo() void;
+extern fn oracle_sdmmc_module_reset() void;
+extern fn oracle_sdmmc_set_card_width(slot: c_uint, width: c_uint) void;
+extern fn oracle_sdmmc_set_block_size(size: c_uint) void;
+extern fn oracle_sdmmc_set_data_transfer_len(len: c_uint) void;
+extern fn oracle_sdmmc_set_timeouts(data_cycles: c_uint, response_cycles: c_uint) void;
+extern fn oracle_sdmmc_set_fifo_threshold(rx: c_uint, tx: c_uint, msize: c_uint) void;
+extern fn oracle_sdmmc_configure_interrupts() void;
+extern fn oracle_sdmmc_init_dma() void;
+extern fn oracle_sdmmc_enable_dma(enable: c_int) void;
+extern fn oracle_sdmmc_set_desc_addr(addr: c_uint) void;
+extern fn oracle_sdmmc_enable_sdio_interrupt(slot: c_uint, enable: c_int) void;
+extern fn oracle_sdmmc_stage_command(
+ index: c_uint,
+ response_long: c_int,
+ response_expect: c_int,
+ check_crc: c_int,
+ data: c_int,
+ send_init: c_int,
+ wait_prvdata: c_int,
+ update_clk: c_int,
+ slot: c_uint,
+) void;
+extern fn oracle_sdmmc_version_id() c_uint;
+extern fn oracle_sdmmc_hw_config() c_uint;
+
+/// Printed by the harness's caller, so a run records which controller it was talking to. A version
+/// ID of 0 or 0xffffffff means the block is gated or absent and every result below is noise.
+pub fn versionId() u32 {
+ return oracle_sdmmc_version_id();
+}
+
+pub fn hwConfig() u32 {
+ return oracle_sdmmc_hw_config();
+}
+
+// Force the whole of `hal/sdmmc.zig` through the compiler for the *chip*.
+//
+// Zig analyses a function only when something references it, and this is the only build that
+// compiles that file for riscv32 at all - the plain application never mentions SDMMC, and the
+// host test root reaches only the pure encoding functions (it cannot reach the rest: reading the
+// `cycle` CSR does not assemble for x86). So without this list, `cmd53Read`, `cardInit` and the
+// whole transfer path would be text that has never been type-checked against the target, which is
+// a bad thing to discover on a board.
+//
+// A `-Doracle` build failing here is the intended behaviour: it means the driver does not
+// compile, and it says so before anything is flashed.
+comptime {
+ _ = &hal.sdmmc.init;
+ _ = &hal.sdmmc.cardInit;
+ _ = &hal.sdmmc.cmd52Read;
+ _ = &hal.sdmmc.cmd52Write;
+ _ = &hal.sdmmc.cmd53Read;
+ _ = &hal.sdmmc.cmd53Write;
+ _ = &hal.sdmmc.slaveInterruptPending;
+ _ = &hal.sdmmc.clearSlaveInterrupt;
+ _ = &hal.sdmmc.setSlaveInterruptEnabled;
+ _ = &hal.sdmmc.rca;
+ _ = &hal.sdmmc.configurePins;
+ _ = &hal.sdmmc.setBusClock;
+ _ = &hal.sdmmc.dividersFor;
+ _ = &hal.sdmmc.cmd52Arg;
+ _ = &hal.sdmmc.cmd53Arg;
+ _ = hal.sdmmc.interrupt_source;
+ _ = hal.sdmmc.bounce_len;
+ _ = hal.sdmmc.c6_pins;
+}
+
+/// The slot under test. Slot 1 is where the ESP32-C6 is; slot 0's pads are the P4's own flash
+/// interface on this board and are never touched.
+const slot: u1 = 1;
+
+const cmd_reg = mmio.Reg.atAddress(@intCast(regs.SDHOST_CMD_REG));
+
+/// Stage the word our HAL would send, with the launch bit removed. `hal.sdmmc.commandWord` is the
+/// code under test; the store is one line and is not.
+fn stage(c: hal.sdmmc.Command) void {
+ cmd_reg.writeRaw(hal.sdmmc.commandWord(c) & ~(@as(u32, 1) << 31));
+}
+
+// -------------------------------------------------------------------------- the register block
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "sdmmc",
+ .base = @intCast(regs.SDHOST_CTRL_REG), // offset 0 of the block
+ // 0x000 through ENSHIFT at +0x110. The window deliberately stops short of BUFFIFO at
+ // +0x200: that is the data FIFO, and a snapshot loop that read it would pop received
+ // words - the same hazard `UART_FIFO_REG` poses at offset 0 of every UART. The three
+ // registers above it (CLK_EDGE_SEL, RAW_INTS, DLL_CLK_CONF at +0x800) belong to the
+ // high-speed delay-line path this driver does not use.
+ .words = 69,
+ .volatile_words = &.{
+ (0x40 - 0x00) / 4, // MINTSTS - the C6 can raise its SDIO interrupt at any moment
+ (0x44 - 0x00) / 4, // RINTSTS - likewise, and write-1-to-clear
+ (0x48 - 0x00) / 4, // STATUS - FIFO count, FSM state, live DAT levels
+ (0x50 - 0x00) / 4, // CDETECT - a live input
+ (0x54 - 0x00) / 4, // WRTPRT - a live input
+ (0x5c - 0x00) / 4, // TCBCNT - transferred card byte count
+ (0x60 - 0x00) / 4, // TBBCNT - transferred host byte count
+ (0x8c - 0x00) / 4, // IDSTS - IDMAC status, write-1-to-clear
+ (0x94 - 0x00) / 4, // DSCADDR - the IDMAC's current descriptor pointer
+ (0x98 - 0x00) / 4, // BUFADDR - the IDMAC's current buffer pointer
+ },
+ // Unlike most of this chip, SDMMC powers up with its bus clock *off*
+ // (HP_SYS_CLKRST SOC_CLK_CTRL1 REG_SDMMC_SYS_CLK_EN, default 0), so this check is the one
+ // that catches a setup that silently did not happen: a gated block returns the last value
+ // latched, not zeros, and two such snapshots compare equal while describing nothing.
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL1_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_SDMMC_SYS_CLK_EN_S),
+ },
+ // LP_AON_CLKRST.hp_sdmmc_emac_rst_ctrl.rst_en_sdmmc - `sdmmc_ll.h:158-163`. Not in
+ // HP_SYS_CLKRST with almost every other peripheral's reset, which is the single most
+ // surprising fact about this block's clock and reset wiring.
+ .restore = .{ .reset_bit = .{
+ .reg = @intCast(regs.LP_CLKRST_HP_SDMMC_EMAC_RST_CTRL_REG),
+ .bit = @intCast(regs.LP_CLKRST_RST_EN_SDMMC_S),
+ } },
+ },
+ .cases = &.{
+ // --- resets. Each of the three bits is self-clearing, so what these compare is mostly
+ // that the *other* bits of CTRL come out the same: a reset function that wrote bit 5
+ // (dma_enable) instead of bit 2 (dma_reset) would leave a trace, and that is exactly the
+ // kind of slip the two undocumented CTRL bits invite.
+ .{ .name = "reset_controller", .idf = idfResetCtl, .ours = ourResetCtl },
+ .{ .name = "reset_fifo", .idf = idfResetFifo, .ours = ourResetFifo },
+ .{ .name = "reset_dma", .idf = idfResetDma, .ours = ourResetDma },
+ .{ .name = "module_reset", .idf = idfModuleReset, .ours = ourModuleReset },
+ // --- the card clock: CLKDIV, CLKSRC, CLKENA. Divider 0 is bypass (40 MHz through the
+ // host divider alone); divider 20 is the 400 kHz probing setting.
+ .{ .name = "card_clock_div", .arg = 0, .idf = idfCardDiv0, .ours = ourCardDiv0 },
+ .{ .name = "card_clock_div", .arg = 20, .idf = idfCardDiv20, .ours = ourCardDiv20 },
+ .{ .name = "card_clock_enable", .arg = 1, .idf = idfCclkOn, .ours = ourCclkOn },
+ .{ .name = "card_clock_low_power", .arg = 0, .idf = idfLpOff, .ours = ourLpOff },
+ .{ .name = "card_clock_low_power", .arg = 1, .idf = idfLpOn, .ours = ourLpOn },
+ // --- bus width. The measured working dump has ctype=0x00000002, i.e. bit 1: slot 1 in
+ // 4-bit mode, which is what `bus_width(4)` must produce and nothing else.
+ .{ .name = "bus_width", .arg = 4, .idf = idfWidth4, .ours = ourWidth4 },
+ .{ .name = "bus_width", .arg = 1, .idf = idfWidth1, .ours = ourWidth1 },
+ // --- transfer geometry.
+ .{ .name = "block_size", .arg = 512, .idf = idfBlk512, .ours = ourBlk512 },
+ .{ .name = "block_size", .arg = 4, .idf = idfBlk4, .ours = ourBlk4 },
+ .{ .name = "timeouts", .idf = idfTimeouts, .ours = ourTimeouts },
+ // A deliberately non-default watermark set, so the case is not "both wrote the reset
+ // value". ESP-IDF has no LL function for FIFOTH at all and never writes the register on
+ // any target, so the reference here goes through IDF's `SDMMC.fifoth` bitfields instead -
+ // which is still IDF's definition of where those three fields sit.
+ .{ .name = "fifo_threshold", .arg = 255, .idf = idfFifoth, .ours = ourFifoth },
+ .{ .name = "fifo_threshold_default", .arg = 511, .idf = idfFifothDefault, .ours = ourFifothDefault },
+ // --- interrupts and DMA.
+ .{ .name = "configure_interrupts", .idf = idfIntrs, .ours = ourIntrs },
+ .{ .name = "sdio_interrupt", .arg = 1, .idf = idfSdioIntOn, .ours = ourSdioIntOn },
+ .{ .name = "init_dma", .idf = idfInitDma, .ours = ourInitDma },
+ .{ .name = "desc_addr", .idf = idfDescAddr, .ours = ourDescAddr },
+ // --- command-word encodings. The five commands `cardInit` sends, plus both directions of
+ // CMD53 and the clock update command that is not a command at all.
+ .{ .name = "cmd_word_cmd0", .arg = 0, .idf = idfCmd0, .ours = ourCmd0 },
+ .{ .name = "cmd_word_cmd5", .arg = 5, .idf = idfCmd5, .ours = ourCmd5 },
+ .{ .name = "cmd_word_cmd3", .arg = 3, .idf = idfCmd3, .ours = ourCmd3 },
+ .{ .name = "cmd_word_cmd7", .arg = 7, .idf = idfCmd7, .ours = ourCmd7 },
+ .{ .name = "cmd_word_cmd52_read", .arg = 52, .idf = idfCmd52R, .ours = ourCmd52R },
+ .{ .name = "cmd_word_cmd52_write", .arg = 52, .idf = idfCmd52W, .ours = ourCmd52W },
+ .{ .name = "cmd_word_cmd53_read", .arg = 53, .idf = idfCmd53R, .ours = ourCmd53R },
+ .{ .name = "cmd_word_cmd53_write", .arg = 53, .idf = idfCmd53W, .ours = ourCmd53W },
+ .{ .name = "cmd_word_clock_update", .idf = idfCmdClk, .ours = ourCmdClk },
+ // --- the peripheral reset itself, which is in LP_AON_CLKRST and observable here only by
+ // its effect: configure the block distinctively through IDF's LL on both sides, then let
+ // each implementation reset it. A `resetPeripheral(.sdmmc)` that wrote the wrong bit -
+ // there is no HP_SYS_CLKRST reset for SDMMC, so writing one is the obvious mistake - would
+ // leave the configuration standing.
+ .{ .name = "peripheral_reset", .idf = idfPeriphReset, .ours = ourPeriphReset },
+ },
+ .setup = setup,
+};
+
+/// Bring the block up far enough that its registers are live, and settle the HAL's idea of which
+/// slot it is driving.
+///
+/// The clock and the reset go through ESP-IDF's LL, not ours: setup runs once, before any case,
+/// and a setup written with the code under test would hide a broken `clkrst.init(.sdmmc)` behind
+/// its own success. `hal.sdmmc.init` runs afterwards for a different reason - it is the only way
+/// to tell the HAL that this is slot 1, and running it here means a bring-up that hangs shows up
+/// as a stalled suite rather than as a wrong register somewhere later. Its result is discarded:
+/// every case restores the block by resetting it, so nothing init leaves behind is load-bearing,
+/// and a card that never answers must not stop the register comparison from running.
+fn setup() void {
+ oracle_sdmmc_bus_clock(1);
+ oracle_sdmmc_reset_register();
+ hal.sdmmc.init(.{ .slot = slot, .width = .four, .khz = 40_000 }) catch {};
+}
+
+fn idfResetCtl() void {
+ oracle_sdmmc_reset_controller();
+}
+fn ourResetCtl() void {
+ mmio.Reg.atAddress(@intCast(regs.SDHOST_CTRL_REG)).modify(.{
+ mmio.Field.of(regs.SDHOST_CONTROLLER_RESET_S, regs.SDHOST_CONTROLLER_RESET_V).is(1),
+ });
+}
+fn idfResetFifo() void {
+ oracle_sdmmc_reset_fifo();
+}
+fn ourResetFifo() void {
+ mmio.Reg.atAddress(@intCast(regs.SDHOST_CTRL_REG)).modify(.{
+ mmio.Field.of(regs.SDHOST_FIFO_RESET_S, regs.SDHOST_FIFO_RESET_V).is(1),
+ });
+}
+fn idfResetDma() void {
+ oracle_sdmmc_reset_dma();
+}
+fn ourResetDma() void {
+ mmio.Reg.atAddress(@intCast(regs.SDHOST_CTRL_REG)).modify(.{
+ mmio.Field.of(regs.SDHOST_DMA_RESET_S, regs.SDHOST_DMA_RESET_V).is(1),
+ });
+}
+fn idfModuleReset() void {
+ oracle_sdmmc_module_reset();
+}
+fn ourModuleReset() void {
+ hal.sdmmc.resetController() catch {};
+}
+
+fn idfCardDiv0() void {
+ oracle_sdmmc_set_card_clock_div(slot, 0);
+}
+fn ourCardDiv0() void {
+ hal.sdmmc.setCardClockDiv(0);
+}
+fn idfCardDiv20() void {
+ oracle_sdmmc_set_card_clock_div(slot, 20);
+}
+fn ourCardDiv20() void {
+ hal.sdmmc.setCardClockDiv(20);
+}
+
+fn idfCclkOn() void {
+ oracle_sdmmc_enable_card_clock(slot, 1);
+}
+fn ourCclkOn() void {
+ hal.sdmmc.setCardClockEnabled(true);
+}
+fn idfLpOff() void {
+ oracle_sdmmc_enable_card_clock_low_power(slot, 0);
+}
+fn ourLpOff() void {
+ hal.sdmmc.setCardClockLowPower(false);
+}
+fn idfLpOn() void {
+ oracle_sdmmc_enable_card_clock_low_power(slot, 1);
+}
+fn ourLpOn() void {
+ hal.sdmmc.setCardClockLowPower(true);
+}
+
+fn idfWidth4() void {
+ oracle_sdmmc_set_card_width(slot, 4);
+}
+fn ourWidth4() void {
+ hal.sdmmc.setBusWidth(.four);
+}
+fn idfWidth1() void {
+ oracle_sdmmc_set_card_width(slot, 1);
+}
+fn ourWidth1() void {
+ hal.sdmmc.setBusWidth(.one);
+}
+
+fn idfBlk512() void {
+ oracle_sdmmc_set_block_size(512);
+ oracle_sdmmc_set_data_transfer_len(512);
+}
+fn ourBlk512() void {
+ hal.sdmmc.setBlockSize(512);
+ hal.sdmmc.setDataTransferLen(512);
+}
+fn idfBlk4() void {
+ // The geometry the measured working dump was taken at: blksiz=4 bytcnt=4, the four-byte
+ // register read ESP-Hosted does to find out how much the slave has queued.
+ oracle_sdmmc_set_block_size(4);
+ oracle_sdmmc_set_data_transfer_len(4);
+}
+fn ourBlk4() void {
+ hal.sdmmc.setBlockSize(4);
+ hal.sdmmc.setDataTransferLen(4);
+}
+
+fn idfTimeouts() void {
+ // 100 ms of card clocks at 40 MHz, and the maximum response timeout - `sd_host_sdmmc.c:531-535`.
+ oracle_sdmmc_set_timeouts(100 * 40_000, 255);
+}
+fn ourTimeouts() void {
+ hal.sdmmc.setTimeouts(100 * 40_000, 255);
+}
+
+fn idfFifoth() void {
+ oracle_sdmmc_set_fifo_threshold(255, 8, 2);
+}
+fn ourFifoth() void {
+ hal.sdmmc.setFifoThreshold(255, 8, 2);
+}
+fn idfFifothDefault() void {
+ oracle_sdmmc_set_fifo_threshold(511, 0, 0);
+}
+fn ourFifothDefault() void {
+ hal.sdmmc.setFifoThreshold(
+ hal.sdmmc.default_rx_watermark,
+ hal.sdmmc.default_tx_watermark,
+ hal.sdmmc.default_dma_msize,
+ );
+}
+
+fn idfIntrs() void {
+ oracle_sdmmc_configure_interrupts();
+}
+fn ourIntrs() void {
+ hal.sdmmc.configureInterrupts();
+}
+fn idfSdioIntOn() void {
+ oracle_sdmmc_enable_sdio_interrupt(slot, 1);
+}
+fn ourSdioIntOn() void {
+ hal.sdmmc.setSlaveInterruptEnabled(true);
+}
+
+fn idfInitDma() void {
+ oracle_sdmmc_init_dma();
+ oracle_sdmmc_enable_dma(1);
+}
+fn ourInitDma() void {
+ hal.sdmmc.initDma();
+ hal.sdmmc.setDmaEnabled(true);
+}
+
+/// An address in L2MEM with the low bits set to something a bug would round away: DBADDR ignores
+/// bits [1:0] internally but stores what is written.
+const test_desc_addr: u32 = 0x4ff1_0140;
+
+fn idfDescAddr() void {
+ oracle_sdmmc_set_desc_addr(test_desc_addr);
+}
+fn ourDescAddr() void {
+ hal.sdmmc.setDescriptorAddr(test_desc_addr);
+}
+
+// The command words. Each pair is the same command expressed twice: once through ESP-IDF's
+// `sdmmc_hw_cmd_t` bitfields, once through this project's `commandWord`.
+
+fn idfCmd0() void {
+ oracle_sdmmc_stage_command(0, 0, 0, 0, 0, 1, 0, 0, slot);
+}
+fn ourCmd0() void {
+ stage(.{ .index = 0, .send_init = true, .wait_prvdata = false, .slot = slot });
+}
+fn idfCmd5() void {
+ oracle_sdmmc_stage_command(5, 0, 1, 0, 0, 0, 1, 0, slot);
+}
+fn ourCmd5() void {
+ stage(.{ .index = 5, .response = .short, .check_crc = false, .slot = slot });
+}
+fn idfCmd3() void {
+ oracle_sdmmc_stage_command(3, 0, 1, 1, 0, 0, 1, 0, slot);
+}
+fn ourCmd3() void {
+ stage(.{ .index = 3, .response = .short, .check_crc = true, .slot = slot });
+}
+fn idfCmd7() void {
+ oracle_sdmmc_stage_command(7, 0, 1, 1, 0, 0, 1, 0, slot);
+}
+fn ourCmd7() void {
+ stage(.{ .index = 7, .response = .short, .check_crc = true, .slot = slot });
+}
+fn idfCmd52R() void {
+ oracle_sdmmc_stage_command(52, 0, 1, 1, 0, 0, 1, 0, slot);
+}
+fn ourCmd52R() void {
+ stage(.{ .index = 52, .response = .short, .check_crc = true, .slot = slot });
+}
+fn idfCmd52W() void {
+ // CMD52 carries its payload in the argument, not in a data phase, so the word is identical to
+ // the read one. Kept as its own case because that is a claim worth checking rather than
+ // assuming: an implementation that set `rw` for a write would fail here and nowhere else.
+ oracle_sdmmc_stage_command(52, 0, 1, 1, 0, 0, 1, 0, slot);
+}
+fn ourCmd52W() void {
+ stage(.{ .index = 52, .response = .short, .check_crc = true, .slot = slot });
+}
+fn idfCmd53R() void {
+ oracle_sdmmc_stage_command(53, 0, 1, 1, 1, 0, 1, 0, slot);
+}
+fn ourCmd53R() void {
+ stage(.{ .index = 53, .response = .short, .check_crc = true, .data = .read, .slot = slot });
+}
+fn idfCmd53W() void {
+ oracle_sdmmc_stage_command(53, 0, 1, 1, 2, 0, 1, 0, slot);
+}
+fn ourCmd53W() void {
+ stage(.{ .index = 53, .response = .short, .check_crc = true, .data = .write, .slot = slot });
+}
+fn idfCmdClk() void {
+ oracle_sdmmc_stage_command(0, 0, 0, 0, 0, 0, 1, 1, slot);
+}
+fn ourCmdClk() void {
+ stage(.{ .index = 0, .update_clock = true, .slot = slot });
+}
+
+/// A configuration distinctive enough that failing to clear it is visible in three registers.
+fn configureDistinctively() void {
+ oracle_sdmmc_set_card_width(slot, 4);
+ oracle_sdmmc_set_block_size(4);
+ oracle_sdmmc_set_fifo_threshold(255, 8, 2);
+}
+
+fn idfPeriphReset() void {
+ configureDistinctively();
+ oracle_sdmmc_reset_register();
+}
+fn ourPeriphReset() void {
+ configureDistinctively();
+ hal.clkrst.resetPeripheral(.sdmmc);
+}
+
+// ------------------------------------------------------------------- the host clock generator
+
+/// The other half of "set the bus to 40 MHz", which is not in the SDMMC block.
+///
+/// `HP_SYS_CLKRST.peri_clk_ctrl01` holds the source mux and the gate, `peri_clk_ctrl02` the
+/// three-edge divider and the driving/sampling phase clocks (`sdmmc_ll.h:212-315`). At 40 MHz the
+/// host divider is 4 and the card divider is 0, so *all* of the division happens here: an
+/// implementation that wrote CLKDIV correctly and this register not at all would clock the C6 at
+/// 160 MHz, which is four times the part's limit and would fail as a wiring problem.
+pub const clock_suite: types.Suite = .{
+ .descriptor = .{
+ .name = "sdmmc_clk",
+ .base = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL1_REG - 0x18), // block base
+ // 0x00 through PERI_CLK_CTRL03 at +0x3c: SOC_CLK_CTRL0..3 (the bus-clock gates) and
+ // PERI_CLK_CTRL00..03 (the SDIO clock generator).
+ .words = 16,
+ // No clock check: HP_SYS_CLKRST is the block that holds every other block's gate and has
+ // none of its own, and one of the cases below deliberately turns SDMMC's off.
+ .restore = .{ .configure = restoreClocks },
+ },
+ .cases = &.{
+ // Disable first, so "enable" is not a no-op against a restored state that already has it
+ // on - the shape clkrst_cases.zig arrived at for the same reason.
+ .{ .name = "bus_clock", .arg = 0, .idf = idfBusClkOff, .ours = ourBusClkOff },
+ .{ .name = "host_clock_div", .arg = 4, .idf = idfHostDiv4, .ours = ourHostDiv4 },
+ .{ .name = "host_clock_div", .arg = 8, .idf = idfHostDiv8, .ours = ourHostDiv8 },
+ .{ .name = "host_clock_div", .arg = 10, .idf = idfHostDiv10, .ours = ourHostDiv10 },
+ .{ .name = "select_clk_source", .idf = idfSelectSrc, .ours = ourSelectSrc },
+ .{ .name = "init_phase_delay", .idf = idfPhase, .ours = ourPhase },
+ // Last, so the block is left clocked whichever side ran last: every suite after this one
+ // that touches SDMMC depends on it.
+ .{ .name = "bus_clock", .arg = 1, .idf = idfBusClkOn, .ours = ourBusClkOn },
+ },
+};
+
+const soc_clk_ctrl1 = mmio.Reg.atAddress(@intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL1_REG));
+const peri01 = mmio.Reg.atAddress(@intCast(regs.HP_SYS_CLKRST_PERI_CLK_CTRL01_REG));
+const peri02 = mmio.Reg.atAddress(@intCast(regs.HP_SYS_CLKRST_PERI_CLK_CTRL02_REG));
+
+/// Every SDIO field of the three registers this suite's cases touch, back to its reset value -
+/// and nothing else, because these words also hold the gates and clock muxes of peripherals that
+/// have nothing to do with SDMMC (MIPI DSI's D-PHY source select is bits 30-31 of PERI_CLK_CTRL02).
+/// Built from register macros only; nothing here calls the code under test.
+fn restoreClocks() void {
+ soc_clk_ctrl1.modify(.{
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDMMC_SYS_CLK_EN_S, regs.HP_SYS_CLKRST_REG_SDMMC_SYS_CLK_EN_V).is(1),
+ });
+ peri01.modify(.{
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_HS_MODE_S, regs.HP_SYS_CLKRST_REG_SDIO_HS_MODE_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_SRC_SEL_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EN_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EN_V).is(0),
+ });
+ peri02.modify(.{
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_L_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_L_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_H_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_H_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_N_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_CLK_EDGE_N_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_SLF_CLK_EDGE_SEL_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_SLF_CLK_EDGE_SEL_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_DRV_CLK_EDGE_SEL_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_DRV_CLK_EDGE_SEL_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_SAM_CLK_EDGE_SEL_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_SAM_CLK_EDGE_SEL_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_SLF_CLK_EN_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_SLF_CLK_EN_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_DRV_CLK_EN_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_DRV_CLK_EN_V).is(0),
+ mmio.Field.of(regs.HP_SYS_CLKRST_REG_SDIO_LS_SAM_CLK_EN_S, regs.HP_SYS_CLKRST_REG_SDIO_LS_SAM_CLK_EN_V).is(0),
+ });
+}
+
+fn idfBusClkOff() void {
+ oracle_sdmmc_bus_clock(0);
+}
+fn ourBusClkOff() void {
+ hal.clkrst.setClockEnabled(.sdmmc, false);
+}
+fn idfBusClkOn() void {
+ oracle_sdmmc_bus_clock(1);
+}
+fn ourBusClkOn() void {
+ hal.clkrst.setClockEnabled(.sdmmc, true);
+}
+
+fn idfHostDiv4() void {
+ oracle_sdmmc_set_host_clock_div(4);
+}
+fn ourHostDiv4() void {
+ hal.sdmmc.setHostClockDiv(4);
+}
+fn idfHostDiv8() void {
+ oracle_sdmmc_set_host_clock_div(8);
+}
+fn ourHostDiv8() void {
+ hal.sdmmc.setHostClockDiv(8);
+}
+fn idfHostDiv10() void {
+ oracle_sdmmc_set_host_clock_div(10);
+}
+fn ourHostDiv10() void {
+ hal.sdmmc.setHostClockDiv(10);
+}
+fn idfSelectSrc() void {
+ oracle_sdmmc_select_clk_source_pll160m();
+}
+fn ourSelectSrc() void {
+ hal.sdmmc.selectPll160m();
+}
+fn idfPhase() void {
+ oracle_sdmmc_init_phase_delay();
+}
+fn ourPhase() void {
+ hal.sdmmc.initPhaseDelay();
+}
diff --git a/src/oracle/sdmmc_ref.c b/src/oracle/sdmmc_ref.c
new file mode 100644
index 0000000..4cc1e59
--- /dev/null
+++ b/src/oracle/sdmmc_ref.c
@@ -0,0 +1,262 @@
+/* SDMMC's reference half: ESP-IDF's own code, compiled into this image.
+ *
+ * Most of what follows is a one-line wrapper over a `sdmmc_ll_*` function, for the same reason
+ * `gpio_ref.c`'s are: the LL functions are `static inline`, so Zig cannot call them until
+ * something gives them external linkage, and anything clever here would be a third implementation
+ * to doubt.
+ *
+ * Three of them are not wrappers, and it is worth being explicit about which and why.
+ *
+ * 1. `oracle_sdmmc_set_fifo_threshold` writes SDHOST_FIFOTH through `SDMMC.fifoth`. There is no
+ * `sdmmc_ll` function for that register - ESP-IDF never writes it, on any target - so there
+ * is nothing to wrap. Writing it through IDF's own bitfield union still makes the bit
+ * positions IDF's, which is the property the comparison needs.
+ *
+ * 2. `oracle_sdmmc_stage_command` transcribes `make_hw_cmd` (sd_trans_sdmmc.c:190-229) and the
+ * three fields `sd_host_slot_start_command` adds afterwards (sd_host_sdmmc.c:859-881).
+ * `make_hw_cmd` is `static` in a `.c` file and unreachable from a header, so this is the one
+ * place the reference is a transcription rather than a call. It is a transcription *into
+ * IDF's `sdmmc_hw_cmd_t`*, so every bit position still comes from
+ * `soc/sdmmc_struct.h:354-485` and not from this file; what is being compared is whether the
+ * Zig side's `Field.of` shifts land in the same places, which is exactly the kind of
+ * transcription error the oracle exists to catch.
+ *
+ * It stages the word with `start_command` cleared. Bit 31 is what launches a command, so a
+ * staged word is inert: the register can be photographed without the CIU trying to talk to a
+ * radio that is still in reset.
+ *
+ * 3. `oracle_sdmmc_configure_controller` and `oracle_sdmmc_module_reset` are short LL sequences,
+ * in the order `sd_host_sdmmc.c` performs them. Sequences are the part of a driver that
+ * register macros cannot express, so a reference for one has to be a sequence too -
+ * `gpio_ref.c`'s `oracle_gpio_matrix_out` is the same shape.
+ */
+
+/* IDF's clock-and-reset LL functions are shadowed by a macro that references
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere; its purpose is to make an
+ * unguarded call fail to compile, because the only legal caller holds a spinlock. There is no
+ * FreeRTOS here and core 1 is held in reset at power-on, so declaring the name is exactly as safe
+ * as the spinlock would be. Same as gpio_ref.c and clkrst_ref.c. */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+/* `sdmmc_ll_set_command` (sdmmc_ll.h:693-696) calls `memcpy`, and this build's `string.h` is an
+ * empty stand-in - the register headers need the name to exist, not its contents. Declaring it
+ * here is enough; the symbol comes from compiler_rt at link time, and at -O2 clang turns a 4-byte
+ * copy into a single store anyway. */
+#include <stdlib.h> /* the shim's `size_t` */
+void *memcpy(void *dst, const void *src, size_t n);
+
+#include "hal/sdmmc_ll.h"
+#include "soc/sdmmc_struct.h"
+
+/* ------------------------------------------------------------------ clocks and reset */
+
+void oracle_sdmmc_bus_clock(int enable)
+{
+ sdmmc_ll_enable_bus_clock(0, enable != 0);
+}
+
+void oracle_sdmmc_reset_register(void)
+{
+ sdmmc_ll_reset_register(0);
+}
+
+void oracle_sdmmc_set_host_clock_div(unsigned div)
+{
+ sdmmc_ll_set_clock_div(&SDMMC, div);
+}
+
+void oracle_sdmmc_select_clk_source_pll160m(void)
+{
+ sdmmc_ll_select_clk_source(&SDMMC, SDMMC_CLK_SRC_PLL160M);
+}
+
+void oracle_sdmmc_init_phase_delay(void)
+{
+ sdmmc_ll_init_phase_delay(&SDMMC);
+}
+
+void oracle_sdmmc_set_card_clock_div(unsigned slot, unsigned div)
+{
+ sdmmc_ll_set_card_clock_div(&SDMMC, slot, div);
+}
+
+void oracle_sdmmc_enable_card_clock(unsigned slot, int enable)
+{
+ sdmmc_ll_enable_card_clock(&SDMMC, slot, enable != 0);
+}
+
+void oracle_sdmmc_enable_card_clock_low_power(unsigned slot, int enable)
+{
+ sdmmc_ll_enable_card_clock_low_power(&SDMMC, slot, enable != 0);
+}
+
+/* ------------------------------------------------------------------ controller resets */
+
+void oracle_sdmmc_reset_controller(void)
+{
+ sdmmc_ll_reset_controller(&SDMMC);
+}
+
+void oracle_sdmmc_reset_dma(void)
+{
+ sdmmc_ll_reset_dma(&SDMMC);
+}
+
+void oracle_sdmmc_reset_fifo(void)
+{
+ sdmmc_ll_reset_fifo(&SDMMC);
+}
+
+/* `s_module_reset` plus its completion poll, sd_host_sdmmc.c:917-950. The poll is what makes this
+ * comparable with the Zig side, which also waits: without it the two could be photographed at
+ * different points in a self-clearing bit's life. */
+void oracle_sdmmc_module_reset(void)
+{
+ sdmmc_ll_reset_controller(&SDMMC);
+ sdmmc_ll_reset_dma(&SDMMC);
+ sdmmc_ll_reset_fifo(&SDMMC);
+ while (!(sdmmc_ll_is_controller_reset_done(&SDMMC) &&
+ sdmmc_ll_is_dma_reset_done(&SDMMC) &&
+ sdmmc_ll_is_fifo_reset_done(&SDMMC))) {
+ /* bounded by the caller: the harness runs this with the bus clock on, where the three bits
+ * clear in a handful of cycles. */
+ }
+}
+
+/* ------------------------------------------------------------------ transfer geometry */
+
+void oracle_sdmmc_set_card_width(unsigned slot, unsigned width)
+{
+ sdmmc_ll_set_card_width(&SDMMC, slot,
+ width == 4 ? SD_BUS_WIDTH_4_BIT : SD_BUS_WIDTH_1_BIT);
+}
+
+void oracle_sdmmc_set_block_size(unsigned size)
+{
+ sdmmc_ll_set_block_size(&SDMMC, size);
+}
+
+void oracle_sdmmc_set_data_transfer_len(unsigned len)
+{
+ sdmmc_ll_set_data_transfer_len(&SDMMC, len);
+}
+
+void oracle_sdmmc_set_timeouts(unsigned data_cycles, unsigned response_cycles)
+{
+ sdmmc_ll_set_data_timeout(&SDMMC, data_cycles);
+ sdmmc_ll_set_response_timeout(&SDMMC, response_cycles);
+}
+
+/* No `sdmmc_ll` function exists for this register; see note 1 at the head of the file. */
+void oracle_sdmmc_set_fifo_threshold(unsigned rx_wmark, unsigned tx_wmark, unsigned msize)
+{
+ SDMMC.fifoth.rx_wmark = rx_wmark;
+ SDMMC.fifoth.tx_wmark = tx_wmark;
+ SDMMC.fifoth.dma_multiple_transaction_size = msize;
+}
+
+/* ------------------------------------------------------------------ interrupts and DMA */
+
+/* sd_host_sdmmc.c:120-124, in order: clear everything, mask everything, global off, unmask the
+ * default set, global on - and then the one thing this project does that ESP-IDF does not: mask
+ * and clear card detect.
+ *
+ * That last pair is a deliberate deviation, so it is expressed here through IDF's own LL rather
+ * than left to differ. There is no card-detect pin on this board; `configurePins` ties the signal
+ * to a matrix constant, the transition latches RINTSTS.cd, and nothing in the command path clears
+ * bit 0 - so an unmasked cd holds the controller's line into the CLIC high forever. Comparing an
+ * IDF sequence that leaves it unmasked against a Zig one that does not would report a difference
+ * that is the point rather than a bug; comparing the same intent on both sides still catches a
+ * wrong bit, a wrong register or a wrong order. */
+void oracle_sdmmc_configure_interrupts(void)
+{
+ sdmmc_ll_clear_interrupt(&SDMMC, 0xffffffff);
+ sdmmc_ll_enable_interrupt(&SDMMC, 0xffffffff, false);
+ sdmmc_ll_enable_global_interrupt(&SDMMC, false);
+ sdmmc_ll_enable_interrupt(&SDMMC, SDMMC_LL_EVENT_DEFAULT, true);
+ sdmmc_ll_enable_interrupt(&SDMMC, SDMMC_LL_EVENT_CD, false);
+ sdmmc_ll_clear_interrupt(&SDMMC, SDMMC_LL_EVENT_CD);
+ sdmmc_ll_enable_global_interrupt(&SDMMC, true);
+}
+
+void oracle_sdmmc_init_dma(void)
+{
+ sdmmc_ll_init_dma(&SDMMC);
+}
+
+void oracle_sdmmc_enable_dma(int enable)
+{
+ sdmmc_ll_enable_dma(&SDMMC, enable != 0);
+}
+
+void oracle_sdmmc_set_desc_addr(unsigned addr)
+{
+ sdmmc_ll_set_desc_addr(&SDMMC, addr);
+}
+
+void oracle_sdmmc_enable_sdio_interrupt(unsigned slot, int enable)
+{
+ sdmmc_ll_enable_interrupt(&SDMMC, slot == 0 ? SDMMC_LL_EVENT_IO_SLOT0 : SDMMC_LL_EVENT_IO_SLOT1,
+ enable != 0);
+}
+
+/* ------------------------------------------------------------------ the command word */
+
+/* make_hw_cmd (sd_trans_sdmmc.c:190-229) + sd_host_slot_start_command's three additions
+ * (sd_host_sdmmc.c:859-881), staged with start_command cleared. See note 2 at the head of the
+ * file for why this one is a transcription.
+ *
+ * `data` is 0 for none, 1 for read, 2 for write - the same three-way choice `cmd->data` and
+ * `SCF_CMD_READ` encode between them. */
+void oracle_sdmmc_stage_command(unsigned index, int response_long, int response_expect,
+ int check_crc, int data, int send_init, int wait_prvdata,
+ int update_clk, unsigned slot)
+{
+ sdmmc_hw_cmd_t res = { 0 };
+
+ res.cmd_index = index;
+ if (send_init) {
+ res.send_init = 1;
+ }
+ if (wait_prvdata) {
+ res.wait_complete = 1;
+ }
+ if (response_expect) {
+ res.response_expect = 1;
+ if (response_long) {
+ res.response_long = 1;
+ }
+ }
+ if (check_crc) {
+ res.check_response_crc = 1;
+ }
+ if (data) {
+ res.data_expected = 1;
+ if (data == 2) {
+ res.rw = 1;
+ }
+ }
+ if (update_clk) {
+ res.update_clk_reg = 1;
+ }
+
+ /* sd_host_slot_start_command: "Outputs should be synchronized to cclk_out". */
+ res.use_hold_reg = 1;
+ res.card_num = slot;
+ /* Deliberately *not* res.start_command = 1: staging, not sending. */
+ res.start_command = 0;
+
+ sdmmc_ll_set_command(&SDMMC, res);
+}
+
+/* ------------------------------------------------------------------ observation */
+
+unsigned oracle_sdmmc_version_id(void)
+{
+ return sdmmc_ll_get_version_id(&SDMMC);
+}
+
+unsigned oracle_sdmmc_hw_config(void)
+{
+ return sdmmc_ll_get_hw_config_info(&SDMMC);
+}
diff --git a/src/oracle/timg_cases.zig b/src/oracle/timg_cases.zig
new file mode 100644
index 0000000..34a31de
--- /dev/null
+++ b/src/oracle/timg_cases.zig
@@ -0,0 +1,435 @@
+//! TIMG's side of the differential test: the same timer and watchdog operations expressed as
+//! ESP-IDF's LL calls and as this project's HAL calls.
+//!
+//! **TIMG1 throughout, never TIMG0.** TIMG0 hosts MWDT0, the watchdog the rest of the system relies
+//! on staying quiet; this image's bootloader has already disabled it. A mistake in a case that ran
+//! against group 0 would not fail a comparison, it would reboot the board mid-run with nothing on
+//! the console to explain it.
+//!
+//! The block is restored by `configure` rather than by the harness pulsing a `reset_bit`, and the
+//! reason is the whole safety story of this peripheral: resetting a timer group re-arms
+//! `WDT_FLASHBOOT_MOD_EN`, which runs the watchdog independently of `WDT_EN`, so a bare reset-bit
+//! pulse arms a watchdog nobody is feeding. `clkrst.resetPeripheral(.timg1)` pulses the bit *and*
+//! clears that flag - exactly as `_timg_ll_reset_register` does (timg_ll.h:60-71) - and the harness's
+//! `reset_bit` path does only the pulse. So the restore goes through the HAL, and the reset sequence
+//! itself becomes one of the cases below instead.
+//!
+//! The restore deliberately leaves the watchdog **write-protected**. That makes the unlock half of
+//! every watchdog case load-bearing: an implementation that forgot to lift protection would have its
+//! stage and prescaler writes silently dropped and would differ from IDF's in the snapshot, rather
+//! than passing because both sides happened to be unlocked already.
+//!
+//! What is *not* here, and why: the timers' function-clock source and per-timer gate live in
+//! HP_SYS_CLKRST (PERI_CLK_CTRL20/21), and the group's bus-clock gate in SOC_CLK_CTRL2, none of
+//! which is inside this block. The harness compares one contiguous window of at most 512 words and
+//! HP_SYS_CLKRST is ~0x1e000 bytes away from TIMG1, so a gate case here would compare two identical
+//! TIMG snapshots and pass no matter what it wrote. Those pairings need a HP_SYS_CLKRST suite of
+//! their own; the reset case below is the one part of that story this window can see, and it does
+//! see it, because a group reset and the flashboot fixup both land in these 64 words.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+const timg = hal.timg;
+
+// ------------------------------------------------------------------- ESP-IDF's side, from timg_ref.c
+
+extern fn oracle_timg_set_divider(group: c_int, timer: c_uint, divider: c_uint) void;
+extern fn oracle_timg_set_direction_up(group: c_int, timer: c_uint, up: c_int) void;
+extern fn oracle_timg_set_auto_reload(group: c_int, timer: c_uint, en: c_int) void;
+extern fn oracle_timg_enable_counter(group: c_int, timer: c_uint, en: c_int) void;
+extern fn oracle_timg_enable_alarm(group: c_int, timer: c_uint, en: c_int) void;
+extern fn oracle_timg_set_alarm_value(group: c_int, timer: c_uint, value: c_ulonglong) void;
+extern fn oracle_timg_set_reload_value(group: c_int, timer: c_uint, value: c_ulonglong) void;
+extern fn oracle_timg_trigger_soft_reload(group: c_int, timer: c_uint) void;
+extern fn oracle_timg_read_counter(group: c_int, timer: c_uint) c_ulonglong;
+extern fn oracle_timg_reset_register(group: c_int) void;
+
+extern fn oracle_mwdt_set_stage(group: c_int, stage: c_uint, timeout: c_uint, action: c_uint) void;
+extern fn oracle_mwdt_disable_stage(group: c_int, stage: c_uint) void;
+extern fn oracle_mwdt_set_prescaler(group: c_int, prescaler: c_uint) void;
+extern fn oracle_mwdt_set_cpu_reset_length(group: c_int, length: c_uint) void;
+extern fn oracle_mwdt_set_sys_reset_length(group: c_int, length: c_uint) void;
+extern fn oracle_mwdt_set_flashboot_en(group: c_int, en: c_int) void;
+extern fn oracle_mwdt_set_enabled(group: c_int, en: c_int) void;
+extern fn oracle_mwdt_feed(group: c_int) void;
+extern fn oracle_mwdt_write_protect_disable(group: c_int) void;
+extern fn oracle_mwdt_write_protect_enable(group: c_int) void;
+
+/// The group under test, as a number for the C side. Deliberately a constant rather than a variable:
+/// unlike GPIO's pin, this is not a parameter to sweep, it is a safety property.
+const group_id: c_int = 1;
+const group: timg.Group = .timg1;
+
+/// The timer under test. A module-level `var` because Zig has no closures and the harness stores
+/// plain `fn` pointers; the suite runs the whole list once per timer in `timers`.
+pub var timer: timg.Timer = .t0;
+
+/// Both general-purpose timers of the group (TIMG_LL_GPTIMERS_PER_INST is 2 on the P4). Worth
+/// sweeping because the timer index is a *stride* in this HAL rather than a separate set of macros,
+/// and a wrong stride writes into the neighbouring timer's registers.
+pub const timers = [_]timg.Timer{ .t0, .t1 };
+
+inline fn timerId() c_uint {
+ return @intFromEnum(timer);
+}
+
+// ------------------------------------------------------------------------------------ restore
+
+fn restore() void {
+ // Pulses HP_RST_EN1's TIMERGRP1 bit and then clears WDT_FLASHBOOT_MOD_EN, which the pulse
+ // re-armed. Both halves matter; see the file comment.
+ // ESP-IDF's reset, not ours: this suite's `reset_register_clears_flashboot` case exists to
+ // compare the two, and restoring with ours would let a no-op reset pass it.
+ oracle_timg_reset_register(group_id);
+ // IDF's reset re-arms flash-boot protection and does not clear it, so clear it here through the
+ // register directly - the board reboots a few seconds later otherwise.
+ mmio.Reg.atAddress(@intCast(regs.TIMG_WDTCONFIG0_REG(1)))
+ .modify(.{mmio.Field.of(regs.TIMG_WDT_FLASHBOOT_MOD_EN_S, regs.TIMG_WDT_FLASHBOOT_MOD_EN_V).is(0)});
+ // Leave write protection on, so every watchdog case has to lift it itself.
+ timg.unlock(group).release();
+}
+
+// ------------------------------------------------------------------------------------- suite
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "timg1",
+ // TIMG_T0CONFIG_REG is at +0x00 of the group's block (timer_group_reg.h:19), and the group
+ // stride is 0x1000 (:14).
+ .base = @intCast(regs.TIMG_T0CONFIG_REG(1)),
+ // 0x100 bytes: the last register in the block is TIMG_REGCLK_REG at +0xfc. The window has to
+ // reach it - TIMG_WDTWPROTECT_REG is at +0x64 and the four stage-timeout registers at
+ // +0x50..+0x5c, so a window that stopped at the timers (+0x48) would be blind to every
+ // watchdog case in this file.
+ .words = 64,
+ .volatile_words = &.{
+ (0x04 - 0x00) / 4, // TIMG_T0LO - the captured counter, which moves between snapshots
+ (0x08 - 0x00) / 4, // TIMG_T0HI
+ (0x28 - 0x00) / 4, // TIMG_T1LO
+ (0x2c - 0x00) / 4, // TIMG_T1HI
+ (0x68 - 0x00) / 4, // TIMG_RTCCALICFG - RTC calibration runs cyclically by default
+ (0x6c - 0x00) / 4, // TIMG_RTCCALICFG1 - and latches a new count each cycle
+ (0x74 - 0x00) / 4, // TIMG_INT_RAW_TIMERS - alarm/watchdog raw status, set by hardware
+ (0x78 - 0x00) / 4, // TIMG_INT_ST_TIMERS
+ (0x80 - 0x00) / 4, // TIMG_RTCCALICFG2
+ },
+ // TIMG1's bus clock: SOC_CLK_CTRL2 bit 22 (hp_sys_clkrst_reg.h:763, and timg_ll.h:35-42
+ // for the register it belongs to - not PERI_CLK_CTRL21, which is where this project's
+ // clkrst table had it until this suite was written). A snapshot of a gated block returns
+ // the last latched value rather than zeros, so the harness checks this first.
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL2_REG),
+ .bit = @intCast(regs.HP_SYS_CLKRST_REG_TIMERGRP1_APB_CLK_EN_S),
+ },
+ .restore = .{ .configure = restore },
+ },
+ .cases = &.{
+ // ---- prescaler. 2 is the hardware minimum and 65536 is the maximum, encoded as 0
+ // (timer_ll.h:191-199) - the one arithmetic edge in this peripheral.
+ .{ .name = "divider", .arg = 2, .idf = idfDivider2, .ours = ourDivider2 },
+ .{ .name = "divider", .arg = 1234, .idf = idfDivider1234, .ours = ourDivider1234 },
+ .{ .name = "divider", .arg = 65535, .idf = idfDivider65535, .ours = ourDivider65535 },
+ .{ .name = "divider_wraps_to_zero", .arg = 65536, .idf = idfDivider65536, .ours = ourDivider65536 },
+ // ---- direction, auto-reload, counter and alarm enables
+ .{ .name = "direction_up", .arg = 1, .idf = idfDirUp, .ours = ourDirUp },
+ .{ .name = "direction_down", .arg = 0, .idf = idfDirDown, .ours = ourDirDown },
+ .{ .name = "auto_reload_on", .arg = 1, .idf = idfReloadOn, .ours = ourReloadOn },
+ .{ .name = "auto_reload_off", .arg = 0, .idf = idfReloadOff, .ours = ourReloadOff },
+ .{ .name = "counter_enable", .arg = 1, .idf = idfCounterOn, .ours = ourCounterOn },
+ .{ .name = "counter_disable", .arg = 0, .idf = idfCounterOff, .ours = ourCounterOff },
+ .{ .name = "alarm_enable", .arg = 1, .idf = idfAlarmOn, .ours = ourAlarmOn },
+ .{ .name = "alarm_disable", .arg = 0, .idf = idfAlarmOff, .ours = ourAlarmOff },
+ // ---- the 54-bit pairs. 0x2a_5555_aaaa exercises all 22 bits of the high word: a value
+ // that fit in 32 bits would pass even if the high half were dropped entirely.
+ .{ .name = "alarm_value_54bit", .arg = 0x5555_aaaa, .idf = idfAlarmValue, .ours = ourAlarmValue },
+ .{ .name = "alarm_value_zero", .arg = 0, .idf = idfAlarmValueZero, .ours = ourAlarmValueZero },
+ .{ .name = "load_value_54bit", .arg = 0x1234_5678, .idf = idfLoadValue, .ours = ourLoadValue },
+ // Write-to-trigger: nothing in the compared window changes, and the counter registers are
+ // volatile. The case is here because it would catch the trigger landing on the wrong
+ // address - TIMG_T0LOAD_REG is one word past TIMG_T0LOADHI_REG - which is a live risk when
+ // the timer index is a stride rather than a distinct macro.
+ .{ .name = "soft_reload_trigger", .idf = idfSoftReload, .ours = ourSoftReload },
+ // The latch-then-read sequence. Register-identical by construction, so what it really
+ // proves is that our poll terminates: this peripheral acknowledges a capture by *clearing*
+ // TxUPDATE, and waiting for it to be set instead hangs the run.
+ .{ .name = "read_counter_latch", .idf = idfReadCounter, .ours = ourReadCounter },
+ // ---- watchdog. Every one of these has to lift write protection and put it back; the
+ // restored state has it on, so a dropped unlock shows up as a difference.
+ .{ .name = "wdt_write_protect_dance", .idf = idfWdtDance, .ours = ourWdtDance },
+ .{ .name = "wdt_stage0_interrupt", .arg = 2_000_000, .idf = idfWdtStage0, .ours = ourWdtStage0 },
+ .{ .name = "wdt_stage1_reset_cpu", .arg = 5_000, .idf = idfWdtStage1, .ours = ourWdtStage1 },
+ .{ .name = "wdt_stage2_reset_system", .arg = 123_456, .idf = idfWdtStage2, .ours = ourWdtStage2 },
+ .{ .name = "wdt_stage3_off", .idf = idfWdtStage3Off, .ours = ourWdtStage3Off },
+ .{ .name = "wdt_prescaler", .arg = 20_000, .idf = idfWdtPrescaler, .ours = ourWdtPrescaler },
+ .{ .name = "wdt_cpu_reset_length", .arg = 7, .idf = idfWdtCpuLen, .ours = ourWdtCpuLen },
+ .{ .name = "wdt_sys_reset_length", .arg = 4, .idf = idfWdtSysLen, .ours = ourWdtSysLen },
+ .{ .name = "wdt_flashboot_off", .arg = 0, .idf = idfWdtFlashbootOff, .ours = ourWdtFlashbootOff },
+ .{ .name = "wdt_feed", .idf = idfWdtFeed, .ours = ourWdtFeed },
+ // Safe on TIMG1 only because the restored state has all four stages off and flashboot mode
+ // cleared, so an enabled watchdog here has no action to take before the next restore.
+ .{ .name = "wdt_enable", .arg = 1, .idf = idfWdtEnable, .ours = ourWdtEnable },
+ .{ .name = "wdt_disable", .arg = 0, .idf = idfWdtDisable, .ours = ourWdtDisable },
+ // ---- the reset sequence itself, which is the only part of the clock/reset table this
+ // window can see: the group reset plus the flashboot fixup that has to follow it.
+ .{ .name = "reset_register_clears_flashboot", .idf = idfResetRegister, .ours = ourResetRegister },
+ },
+ .setup = setup,
+};
+
+/// The group's bus clock. Already 1 out of reset (hp_sys_clkrst_reg.h:763, default 1) and this image
+/// never runs `esp_perip_clk_init`, so this is belt-and-braces - but a snapshot of a gated block is
+/// stale rather than zero, and the harness would rather fail the gate check than compare noise.
+fn setup() void {
+ hal.clkrst.setClockEnabled(.timg1, true);
+}
+
+// -------------------------------------------------------------------------- the case pairs
+// Same operation, same arguments, twice. IDF's LL on one side, this HAL on the other; a read-back
+// through our own accessor would prove nothing, which is the whole point of the arrangement.
+
+fn idfDivider2() void {
+ oracle_timg_set_divider(group_id, timerId(), 2);
+}
+fn ourDivider2() void {
+ timg.setDivider(group, timer, 2);
+}
+fn idfDivider1234() void {
+ oracle_timg_set_divider(group_id, timerId(), 1234);
+}
+fn ourDivider1234() void {
+ timg.setDivider(group, timer, 1234);
+}
+fn idfDivider65535() void {
+ oracle_timg_set_divider(group_id, timerId(), 65535);
+}
+fn ourDivider65535() void {
+ timg.setDivider(group, timer, 65535);
+}
+fn idfDivider65536() void {
+ oracle_timg_set_divider(group_id, timerId(), 65536);
+}
+fn ourDivider65536() void {
+ timg.setDivider(group, timer, 65536);
+}
+
+fn idfDirUp() void {
+ oracle_timg_set_direction_up(group_id, timerId(), 1);
+}
+fn ourDirUp() void {
+ timg.setDirection(group, timer, .up);
+}
+fn idfDirDown() void {
+ oracle_timg_set_direction_up(group_id, timerId(), 0);
+}
+fn ourDirDown() void {
+ timg.setDirection(group, timer, .down);
+}
+
+fn idfReloadOn() void {
+ oracle_timg_set_auto_reload(group_id, timerId(), 1);
+}
+fn ourReloadOn() void {
+ timg.setAutoReload(group, timer, true);
+}
+fn idfReloadOff() void {
+ oracle_timg_set_auto_reload(group_id, timerId(), 0);
+}
+fn ourReloadOff() void {
+ timg.setAutoReload(group, timer, false);
+}
+
+fn idfCounterOn() void {
+ oracle_timg_enable_counter(group_id, timerId(), 1);
+}
+fn ourCounterOn() void {
+ timg.setCounterEnabled(group, timer, true);
+}
+fn idfCounterOff() void {
+ oracle_timg_enable_counter(group_id, timerId(), 0);
+}
+fn ourCounterOff() void {
+ timg.setCounterEnabled(group, timer, false);
+}
+
+fn idfAlarmOn() void {
+ oracle_timg_enable_alarm(group_id, timerId(), 1);
+}
+fn ourAlarmOn() void {
+ timg.setAlarmEnabled(group, timer, true);
+}
+fn idfAlarmOff() void {
+ oracle_timg_enable_alarm(group_id, timerId(), 0);
+}
+fn ourAlarmOff() void {
+ timg.setAlarmEnabled(group, timer, false);
+}
+
+/// 54 bits: 22 in the high word, 32 in the low one.
+const alarm_value: u64 = 0x2a_5555_aaaa;
+const load_value: u64 = 0x15_1234_5678;
+
+fn idfAlarmValue() void {
+ oracle_timg_set_alarm_value(group_id, timerId(), alarm_value);
+}
+fn ourAlarmValue() void {
+ timg.setAlarmValue(group, timer, alarm_value);
+}
+fn idfAlarmValueZero() void {
+ oracle_timg_set_alarm_value(group_id, timerId(), 0);
+}
+fn ourAlarmValueZero() void {
+ timg.setAlarmValue(group, timer, 0);
+}
+fn idfLoadValue() void {
+ oracle_timg_set_reload_value(group_id, timerId(), load_value);
+}
+fn ourLoadValue() void {
+ timg.setLoadValue(group, timer, load_value);
+}
+fn idfSoftReload() void {
+ oracle_timg_set_reload_value(group_id, timerId(), load_value);
+ oracle_timg_trigger_soft_reload(group_id, timerId());
+}
+fn ourSoftReload() void {
+ timg.setLoadValue(group, timer, load_value);
+ timg.load(group, timer);
+}
+
+fn idfReadCounter() void {
+ _ = oracle_timg_read_counter(group_id, timerId());
+}
+fn ourReadCounter() void {
+ // Discarding the value is the point: the comparison is over registers, and what this exercises
+ // is the handshake. A null return means our poll gave up after 10,000 reads, which IDF's
+ // version cannot report because it spins forever.
+ _ = timg.read(group, timer);
+}
+
+// ------------------------------------------------------------------------------ watchdog pairs
+
+fn idfWdtDance() void {
+ oracle_mwdt_write_protect_disable(group_id);
+ oracle_mwdt_write_protect_enable(group_id);
+}
+fn ourWdtDance() void {
+ const wdt = timg.unlock(group);
+ wdt.release();
+}
+
+fn idfWdtStage0() void {
+ oracle_mwdt_set_stage(group_id, 0, 2_000_000, @intFromEnum(timg.Action.interrupt));
+}
+fn ourWdtStage0() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setStage(.stage0, 2_000_000, .interrupt);
+}
+
+fn idfWdtStage1() void {
+ oracle_mwdt_set_stage(group_id, 1, 5_000, @intFromEnum(timg.Action.reset_cpu));
+}
+fn ourWdtStage1() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setStage(.stage1, 5_000, .reset_cpu);
+}
+
+fn idfWdtStage2() void {
+ oracle_mwdt_set_stage(group_id, 2, 123_456, @intFromEnum(timg.Action.reset_system));
+}
+fn ourWdtStage2() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setStage(.stage2, 123_456, .reset_system);
+}
+
+fn idfWdtStage3Off() void {
+ // Configure it to something first, so "off" has something to undo and the case cannot pass by
+ // both sides doing nothing.
+ oracle_mwdt_set_stage(group_id, 3, 999, @intFromEnum(timg.Action.interrupt));
+ oracle_mwdt_disable_stage(group_id, 3);
+}
+fn ourWdtStage3Off() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setStage(.stage3, 999, .interrupt);
+ wdt.disableStage(.stage3);
+}
+
+fn idfWdtPrescaler() void {
+ oracle_mwdt_set_prescaler(group_id, 20_000);
+}
+fn ourWdtPrescaler() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setPrescaler(20_000);
+}
+
+fn idfWdtCpuLen() void {
+ oracle_mwdt_set_cpu_reset_length(group_id, @intFromEnum(timg.ResetLength.us_3_2));
+}
+fn ourWdtCpuLen() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setCpuResetLength(.us_3_2);
+}
+
+fn idfWdtSysLen() void {
+ oracle_mwdt_set_sys_reset_length(group_id, @intFromEnum(timg.ResetLength.ns_500));
+}
+fn ourWdtSysLen() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setSysResetLength(.ns_500);
+}
+
+fn idfWdtFlashbootOff() void {
+ oracle_mwdt_set_flashboot_en(group_id, 0);
+}
+fn ourWdtFlashbootOff() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setFlashbootEnabled(false);
+}
+
+fn idfWdtFeed() void {
+ oracle_mwdt_feed(group_id);
+}
+fn ourWdtFeed() void {
+ timg.feed(group);
+}
+
+fn idfWdtEnable() void {
+ oracle_mwdt_set_enabled(group_id, 1);
+}
+fn ourWdtEnable() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setEnabled(true);
+}
+
+fn idfWdtDisable() void {
+ oracle_mwdt_set_enabled(group_id, 0);
+}
+fn ourWdtDisable() void {
+ const wdt = timg.unlock(group);
+ defer wdt.release();
+ wdt.setEnabled(false);
+}
+
+fn idfResetRegister() void {
+ oracle_timg_reset_register(group_id);
+}
+fn ourResetRegister() void {
+ // ESP-IDF's reset, not ours: this suite's `reset_register_clears_flashboot` case exists to
+ // compare the two, and restoring with ours would let a no-op reset pass it.
+ oracle_timg_reset_register(group_id);
+ // IDF's reset re-arms flash-boot protection and does not clear it, so clear it here through the
+ // register directly - the board reboots a few seconds later otherwise.
+ mmio.Reg.atAddress(@intCast(regs.TIMG_WDTCONFIG0_REG(1)))
+ .modify(.{mmio.Field.of(regs.TIMG_WDT_FLASHBOOT_MOD_EN_S, regs.TIMG_WDT_FLASHBOOT_MOD_EN_V).is(0)});
+}
diff --git a/src/oracle/timg_ref.c b/src/oracle/timg_ref.c
new file mode 100644
index 0000000..38bccc8
--- /dev/null
+++ b/src/oracle/timg_ref.c
@@ -0,0 +1,197 @@
+/* The reference implementation for the timer groups and their watchdogs, which is ESP-IDF's own.
+ *
+ * Thin external-linkage wrappers over `timer_ll.h`, `mwdt_ll.h` and `timg_ll.h`, so Zig can call
+ * IDF's `static inline` functions and the differential harness can run both implementations in one
+ * image on one boot. There is no logic here: anything clever would be a third implementation to
+ * doubt.
+ *
+ * Two things about the watchdog wrappers are deliberate. The write-protect dance is *inside* each
+ * wrapper (`mwdt_ll_write_protect_disable` ... `mwdt_ll_write_protect_enable`) because that is what
+ * IDF's callers do - `mwdt_ll_config_stage` itself will silently do nothing if protection is on -
+ * and because our side does the same thing through `timg.unlock`/`release`. The two sides have to be
+ * the same operation, key register included, or comparing WDTWPROTECT afterwards means nothing.
+ * And nothing here ever calls `mwdt_ll_enable` on group 0: TIMG0 hosts the watchdog the rest of the
+ * system depends on not firing.
+ */
+
+/* IDF's clock and reset LL functions are shadowed by a wrapper macro referencing
+ * `__DECLARE_RCC_ATOMIC_ENV` / `__DECLARE_RCC_RC_ATOMIC_ENV`, identifiers IDF never defines
+ * anywhere: their purpose is to make an unguarded call fail to compile, because the only legal
+ * caller holds a FreeRTOS spinlock. There is no FreeRTOS here and core 1 is held in reset at
+ * power-on, so declaring the names is exactly as safe as the spinlock would be - and it is what
+ * IDF's own bootloader does (bootloader_support/src/bootloader_console.c:53). */
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+static int __DECLARE_RCC_RC_ATOMIC_ENV __attribute__((unused));
+
+#include "hal/timer_ll.h"
+#include "hal/mwdt_ll.h"
+#include "hal/timg_ll.h"
+#include "soc/timer_group_struct.h"
+
+static timg_dev_t *grp(int group)
+{
+ return TIMER_LL_GET_HW(group);
+}
+
+/* ------------------------------------------------------------------ general purpose timer */
+
+void oracle_timg_set_divider(int group, unsigned timer, unsigned divider)
+{
+ timer_ll_set_clock_prescale(grp(group), timer, divider);
+}
+
+void oracle_timg_set_direction_up(int group, unsigned timer, int up)
+{
+ timer_ll_set_count_direction(grp(group), timer, up ? GPTIMER_COUNT_UP : GPTIMER_COUNT_DOWN);
+}
+
+void oracle_timg_set_auto_reload(int group, unsigned timer, int en)
+{
+ timer_ll_enable_auto_reload(grp(group), timer, en != 0);
+}
+
+void oracle_timg_enable_counter(int group, unsigned timer, int en)
+{
+ timer_ll_enable_counter(grp(group), timer, en != 0);
+}
+
+void oracle_timg_enable_alarm(int group, unsigned timer, int en)
+{
+ timer_ll_enable_alarm(grp(group), timer, en != 0);
+}
+
+void oracle_timg_set_alarm_value(int group, unsigned timer, unsigned long long value)
+{
+ timer_ll_set_alarm_value(grp(group), timer, value);
+}
+
+void oracle_timg_set_reload_value(int group, unsigned timer, unsigned long long value)
+{
+ timer_ll_set_reload_value(grp(group), timer, value);
+}
+
+unsigned long long oracle_timg_get_reload_value(int group, unsigned timer)
+{
+ return timer_ll_get_reload_value(grp(group), timer);
+}
+
+void oracle_timg_trigger_soft_reload(int group, unsigned timer)
+{
+ timer_ll_trigger_soft_reload(grp(group), timer);
+}
+
+/* The latch-then-read sequence: `timer_ll_trigger_soft_capture` writes TxUPDATE and spins until the
+ * hardware clears it, and only then is the TxHI/TxLO pair meaningful. Exposed as one call because
+ * that is how our `timg.read` expresses it, and splitting it would compare halves of a sequence. */
+unsigned long long oracle_timg_read_counter(int group, unsigned timer)
+{
+ timer_ll_trigger_soft_capture(grp(group), timer);
+ return timer_ll_get_counter_value(grp(group), timer);
+}
+
+void oracle_timg_set_clock_source_xtal(int group, unsigned timer)
+{
+ timer_ll_set_clock_source(group, timer, GPTIMER_CLK_SRC_XTAL);
+}
+
+void oracle_timg_set_clock_source_pll80m(int group, unsigned timer)
+{
+ timer_ll_set_clock_source(group, timer, GPTIMER_CLK_SRC_PLL_F80M);
+}
+
+void oracle_timg_enable_timer_clock(int group, unsigned timer, int en)
+{
+ timer_ll_enable_clock(group, timer, en != 0);
+}
+
+void oracle_timg_enable_bus_clock(int group, int en)
+{
+ timg_ll_enable_bus_clock(group, en != 0);
+}
+
+/* Pulses the group's reset bit and then clears WDT_FLASHBOOT_MOD_EN, which the reset re-arms
+ * (timg_ll.h:51-71). The clearing is the interesting half: leave it out and the board reboots a
+ * moment later with nothing on the console to explain it. */
+void oracle_timg_reset_register(int group)
+{
+ timg_ll_reset_register(group);
+}
+
+/* --------------------------------------------------------------------------------- watchdog */
+
+void oracle_mwdt_set_stage(int group, unsigned stage, unsigned timeout, unsigned action)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_config_stage(grp(group), (wdt_stage_t)stage, timeout, (wdt_stage_action_t)action);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_disable_stage(int group, unsigned stage)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_disable_stage(grp(group), stage);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_set_prescaler(int group, unsigned prescaler)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_set_prescaler(grp(group), prescaler);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_set_cpu_reset_length(int group, unsigned length)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_set_cpu_reset_length(grp(group), (wdt_reset_sig_length_t)length);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_set_sys_reset_length(int group, unsigned length)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_set_sys_reset_length(grp(group), (wdt_reset_sig_length_t)length);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_set_flashboot_en(int group, int en)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_set_flashboot_en(grp(group), en != 0);
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_set_enabled(int group, int en)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ if (en) {
+ mwdt_ll_enable(grp(group));
+ } else {
+ mwdt_ll_disable(grp(group));
+ }
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+void oracle_mwdt_feed(int group)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+ mwdt_ll_feed(grp(group));
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+/* The two halves of the protection dance on their own, so a case can check that our key value and
+ * IDF's are the same word rather than only that a guarded sequence ends up locked. */
+void oracle_mwdt_write_protect_disable(int group)
+{
+ mwdt_ll_write_protect_disable(grp(group));
+}
+
+void oracle_mwdt_write_protect_enable(int group)
+{
+ mwdt_ll_write_protect_enable(grp(group));
+}
+
+int oracle_mwdt_is_enabled(int group)
+{
+ return mwdt_ll_check_if_enabled(grp(group)) ? 1 : 0;
+}
diff --git a/src/oracle/uart_cases.zig b/src/oracle/uart_cases.zig
new file mode 100644
index 0000000..444d235
--- /dev/null
+++ b/src/oracle/uart_cases.zig
@@ -0,0 +1,326 @@
+//! UART's side of the differential test.
+//!
+//! **The peripheral under test is UART1, and that is a safety constraint rather than a preference.**
+//! UART0 carries this board's console. The harness restores a UART by pulsing its reset bit, and
+//! resetting UART0 clears UART_CLKDIV: the console's output turns to garbage mid-character and the
+//! board takes a watchdog reset with nothing readable left to explain it. That was measured on this
+//! board. UART1 is otherwise idle here, has no pins routed at power-on, and resets cleanly.
+//!
+//! **Word 0 is on the no-read list.** `UART_FIFO_REG` is at offset 0x000 - the first word any "read
+//! the whole block" loop touches - its only field is annotated `RO` in uart_reg.h:18, and that
+//! annotation is wrong in the way that matters: the read is the FIFO pop. A generic snapshot of a
+//! UART eats received bytes.
+//!
+//! **What this suite cannot see, stated plainly.** The descriptor is one contiguous window and the
+//! UART's is 0xa0 bytes at its own base, so three things this HAL does land outside it:
+//!
+//! * the integer pre-divider `REG_UART1_SCLK_DIV_NUM` and the source select
+//! `REG_UART1_CLK_SRC_SEL`, which are in HP_SYS_CLKRST at a different base;
+//! * the GPIO matrix registers the routing cases write, which are in the GPIO block;
+//! * the FIFO contents themselves, which have no addressable state to compare.
+//!
+//! The pre-divider is not unobserved, though, only observed indirectly: `clk_div` is
+//! `(sclk_freq << 4) / (baud * sclk_div)`, so the in-window CLKDIV_SYNC word is a function of the
+//! pre-divider, and the two sides disagreeing on `sclk_div` shows up as a different CLKDIV unless
+//! the two errors cancel exactly. The `baud_300` case exists specifically because it is the one
+//! rate here whose pre-divider is not 1. The routing cases are honestly weak in this window - what
+//! they compare is that both sides leave the *UART* untouched, and their real evidence is that
+//! `gpio_cases`' `matrix_out` case passes against the same GPIO LL functions this file calls.
+//!
+//! Both sides reach the hardware by different paths throughout: the `idf` half calls ESP-IDF's
+//! `uart_ll.h` compiled by clang, the `ours` half calls src/hal/uart.zig. Nothing here reads a
+//! value back through the accessor that wrote it, because that proves only that the accessor is
+//! self-consistent.
+
+const std = @import("std");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const types = @import("differ_types.zig");
+
+extern fn oracle_uart_set_sclk(num: c_uint, sel: c_uint) void;
+/// The source select, named on the C side. `UART_SCLK_XTAL` is a `soc_module_clk_t` enumerator whose
+/// numeric value is an accident of a chip-wide enum, so it must not cross this boundary as an
+/// integer - passing 0 selects nothing that exists and hangs the next commit.
+extern fn oracle_uart_set_sclk_xtal(num: c_uint) void;
+extern fn oracle_uart_sclk_enable(num: c_uint) void;
+extern fn oracle_uart_enable_bus_clock(num: c_uint, enable: c_int) void;
+extern fn oracle_uart_set_baudrate(num: c_uint, baud: c_uint, sclk_freq: c_uint) c_int;
+extern fn oracle_uart_set_data_bit_num(num: c_uint, bits: c_uint) void;
+extern fn oracle_uart_set_stop_bits(num: c_uint, stop: c_uint) void;
+extern fn oracle_uart_set_parity(num: c_uint, parity: c_uint) void;
+extern fn oracle_uart_txfifo_rst(num: c_uint) void;
+extern fn oracle_uart_rxfifo_rst(num: c_uint) void;
+extern fn oracle_uart_set_loop_back(num: c_uint, enable: c_int) void;
+extern fn oracle_uart_update(num: c_uint) void;
+extern fn oracle_uart_route_tx(num: c_uint, pin: c_uint) void;
+extern fn oracle_uart_route_rx(num: c_uint, pin: c_uint) void;
+
+/// The instance under test. A module-level `var` because Zig has no closures and the harness stores
+/// plain `fn` pointers. It is a `var` rather than a constant so a future run can move to UART2-4,
+/// but it must never become 0: see this file's header.
+pub var port: u8 = 1;
+
+/// The pad the routing cases use. GPIO33 is a free pin on this board's JP1 header - the same one
+/// `gpio_cases` uses for its high-bank tests, and for the same reason.
+pub var route_pin: u8 = 33;
+
+/// The clock source frequency the baud cases assume, matching what `setup` selects. XTAL is 40 MHz
+/// on the P4 and is the only source whose frequency is exact, which is what makes an expected
+/// divider computable by hand.
+const sclk_freq: u32 = 40_000_000;
+
+fn ours() hal.uart.Uart {
+ return hal.uart.Uart.init(port);
+}
+
+/// Bring UART1 far enough up that its registers answer and its baud generator runs: APB bus clock,
+/// core clock, and a source select. Done through IDF's LL rather than ours, so that a bug in our
+/// clock code cannot make the whole suite silently compare two dead blocks - and the harness
+/// re-checks the bus clock gate before every case regardless.
+fn setup() void {
+ restore();
+}
+
+/// Known state: out of reset, bus clock on, core clock on, source selected. Every case starts here.
+///
+/// The reset is what makes this a sound restore for a block whose CONF0_SYNC carries two
+/// write-to-act FIFO resets and whose offset 0 transmits when written - there is nothing here that
+/// could be restored by writing a saved snapshot back. The re-enable is what makes it *usable*
+/// afterwards.
+fn restore() void {
+ const guard = hal.clkrst.maskInterrupts();
+ const rst = mmio.Reg.at(regs.HP_SYS_CLKRST_HP_RST_EN1_REG);
+ const bit = @as(u32, 1) << regs.HP_SYS_CLKRST_REG_RST_EN_UART1_APB_S;
+ rst.writeRaw(rst.raw() | bit);
+ rst.writeRaw(rst.raw() & ~bit);
+ guard.release();
+
+ oracle_uart_enable_bus_clock(port, 1);
+ oracle_uart_sclk_enable(port);
+ oracle_uart_set_sclk_xtal(port);
+}
+
+// The clock source is selected through oracle_uart_set_sclk_xtal, which names the enumerator on the
+// C side. It used to be an integer constant here, and 0 is not XTAL - see that function's comment.
+
+pub const suite: types.Suite = .{
+ .descriptor = .{
+ .name = "uart1",
+ // UART1's block: DR_REG_UART0_BASE + 1 * 0x1000 (soc.h:20).
+ .base = @intCast(regs.DR_REG_UART0_BASE + 0x1000),
+ // 40 words, 0x000 through 0x09c. The last register in the block is UART_ID at +0x9c
+ // (uart_reg.h:1568) and the commit bit UART_REG_UPDATE is at +0x98 - a window that stopped
+ // at UART_CLK_CONF (+0x88) would be blind to whether the commit even happened, which is the
+ // single most likely difference against IDF on this peripheral.
+ .words = 40,
+ // The read that is a write. See the header.
+ .no_read = &.{0x00 / 4},
+ .volatile_words = &.{
+ 0x04 / 4, // UART_INT_RAW - write-1-to-clear, and TXFIFO_EMPTY_INT_RAW moves on its own
+ 0x08 / 4, // UART_INT_ST - read-only view of the above
+ 0x1c / 4, // UART_STATUS - live FIFO counts, and the RXD/CTS/DSR pad levels
+ 0x68 / 4, // UART_MEM_TX_STATUS - FIFO read/write pointers
+ 0x6c / 4, // UART_MEM_RX_STATUS
+ 0x70 / 4, // UART_FSM_STATUS - the transmitter's state machine
+ 0x74 / 4, // UART_POSPULSE - autobaud edge counters, which count whatever the pad does
+ 0x78 / 4, // UART_NEGPULSE
+ 0x7c / 4, // UART_LOWPULSE
+ 0x80 / 4, // UART_HIGHPULSE
+ 0x84 / 4, // UART_RXD_CNT
+ 0x90 / 4, // UART_AFIFO_STATUS - the async FIFO's empty/full flags
+ 0x98 / 4, // UART_REG_UPDATE - self-clearing; reads 0 once the commit lands, but is
+ // 1 for a few core-clock cycles and a snapshot can catch it
+ },
+ // The APB gate that must read 1 for a snapshot of this block to mean anything. A gated UART
+ // does not read as zeros, it reads as the last value latched, so two meaningless snapshots
+ // can compare equal. Pairing from uart_ll.h:257-259, which reads UART1's APB enable out of
+ // HP_SYS_CLKRST.soc_clk_ctrl2.
+ .clock = .{
+ .reg = @intCast(regs.HP_SYS_CLKRST_SOC_CLK_CTRL2_REG),
+ .bit = regs.HP_SYS_CLKRST_REG_UART1_APB_CLK_EN_S,
+ },
+ // Reset is the only sound restore for this block: CONF0_SYNC's two FIFO-reset bits and
+ // REG_UPDATE are write-to-act, and writing a saved word back to offset 0x000 would transmit
+ // a character. Pairing from uart_ll.h:340-342. Safe here only because this is UART1;
+ // the same line for UART0 kills the console.
+ // Reset, and then put the clocking back - which is why this is `.configure` and not
+ // `.reset_bit`. The harness's reset path does only the pulse, and a UART reset clears the
+ // core-clock enable and the source select along with everything else. IDF's
+ // `uart_ll_update` then spins forever waiting for a REG_UPDATE commit that a clockless
+ // peripheral will never acknowledge: the harness reached the first UART case and stopped,
+ // with the console silent, looking exactly like a crash.
+ .restore = .{ .configure = restore },
+ },
+ .cases = &.{
+ // --- baud rate. Four rates spanning the interesting parts of the arithmetic: two ordinary
+ // ones where the pre-divider is 1, one low enough to need a pre-divider of 33, and one fast
+ // enough that the integer part gets small and the fraction carries most of the accuracy.
+ .{ .name = "baudrate", .arg = 115200, .idf = idfBaud115200, .ours = ourBaud115200 },
+ .{ .name = "baudrate", .arg = 9600, .idf = idfBaud9600, .ours = ourBaud9600 },
+ .{ .name = "baudrate_needs_predivider", .arg = 300, .idf = idfBaud300, .ours = ourBaud300 },
+ .{ .name = "baudrate", .arg = 1000000, .idf = idfBaud1M, .ours = ourBaud1M },
+
+ // --- data format. Each of these is one CONF0_SYNC field plus a commit.
+ .{ .name = "word_length", .arg = 8, .idf = idfBits8, .ours = ourBits8 },
+ .{ .name = "word_length", .arg = 5, .idf = idfBits5, .ours = ourBits5 },
+ .{ .name = "stop_bits", .arg = 2, .idf = idfStop2, .ours = ourStop2 },
+ .{ .name = "stop_bits_1_5", .arg = 15, .idf = idfStop15, .ours = ourStop15 },
+ .{ .name = "parity_odd", .arg = 3, .idf = idfParityOdd, .ours = ourParityOdd },
+ .{ .name = "parity_even", .arg = 2, .idf = idfParityEven, .ours = ourParityEven },
+ // The asymmetric one: IDF leaves the odd/even bit alone when disabling parity, because 0
+ // carries no odd/even information (uart_ll.h:819-822). Setting odd and then disabling is
+ // the sequence that makes the difference visible, so the case does both.
+ .{ .name = "parity_odd_then_disable", .idf = idfParityOddThenOff, .ours = ourParityOddThenOff },
+
+ // --- loopback. Worth a case of its own beyond being one more CONF0_SYNC bit: it is the only
+ // way to move a byte through this UART with nothing wired to the board.
+ .{ .name = "loopback_on", .arg = 1, .idf = idfLoopOn, .ours = ourLoopOn },
+ .{ .name = "loopback_off", .arg = 0, .idf = idfLoopOff, .ours = ourLoopOff },
+
+ // --- FIFO resets. These are sequences, not field writes: assert, commit, deassert, commit,
+ // four stores where a state comparison alone would accept one. Getting the commits wrong
+ // leaves the register reading exactly as asked and the FIFO not reset.
+ .{ .name = "txfifo_rst", .idf = idfTxFifoRst, .ours = ourTxFifoRst },
+ .{ .name = "rxfifo_rst", .idf = idfRxFifoRst, .ours = ourRxFifoRst },
+
+ // --- the bare commit, as its own case. If this one differs, every case above is suspect.
+ .{ .name = "update", .idf = idfUpdate, .ours = ourUpdate },
+
+ // --- pin routing. Window-blind by construction: the effect is in the GPIO block, so what
+ // these compare is that neither side disturbs the UART while routing. Kept because a
+ // routing call that accidentally wrote a UART register would be caught by nothing else, and
+ // because the pair documents which signal index each side uses.
+ .{ .name = "route_tx", .arg = 33, .idf = idfRouteTx, .ours = ourRouteTx },
+ .{ .name = "route_rx", .arg = 33, .idf = idfRouteRx, .ours = ourRouteRx },
+ },
+ .setup = setup,
+};
+
+// ------------------------------------------------------------------------------------- baud rate
+
+fn idfBaud115200() void {
+ _ = oracle_uart_set_baudrate(port, 115200, sclk_freq);
+}
+fn ourBaud115200() void {
+ _ = ours().setBaudrate(115200, sclk_freq);
+}
+fn idfBaud9600() void {
+ _ = oracle_uart_set_baudrate(port, 9600, sclk_freq);
+}
+fn ourBaud9600() void {
+ _ = ours().setBaudrate(9600, sclk_freq);
+}
+fn idfBaud300() void {
+ _ = oracle_uart_set_baudrate(port, 300, sclk_freq);
+}
+fn ourBaud300() void {
+ _ = ours().setBaudrate(300, sclk_freq);
+}
+fn idfBaud1M() void {
+ _ = oracle_uart_set_baudrate(port, 1_000_000, sclk_freq);
+}
+fn ourBaud1M() void {
+ _ = ours().setBaudrate(1_000_000, sclk_freq);
+}
+
+// ----------------------------------------------------------------------------------- data format
+// The numeric arguments to IDF's side are its own enum values from uart_types.h: word length is
+// (bits - 5), stop bits are 1/2/3 for 1/1.5/2, parity is 0/2/3 for disable/even/odd.
+
+fn idfBits8() void {
+ oracle_uart_set_data_bit_num(port, 3);
+}
+fn ourBits8() void {
+ ours().setWordLength(.bits8);
+}
+fn idfBits5() void {
+ oracle_uart_set_data_bit_num(port, 0);
+}
+fn ourBits5() void {
+ ours().setWordLength(.bits5);
+}
+fn idfStop2() void {
+ oracle_uart_set_stop_bits(port, 3);
+}
+fn ourStop2() void {
+ ours().setStopBits(.two);
+}
+fn idfStop15() void {
+ oracle_uart_set_stop_bits(port, 2);
+}
+fn ourStop15() void {
+ ours().setStopBits(.one_and_half);
+}
+fn idfParityOdd() void {
+ oracle_uart_set_parity(port, 3);
+}
+fn ourParityOdd() void {
+ ours().setParity(.odd);
+}
+fn idfParityEven() void {
+ oracle_uart_set_parity(port, 2);
+}
+fn ourParityEven() void {
+ ours().setParity(.even);
+}
+fn idfParityOddThenOff() void {
+ oracle_uart_set_parity(port, 3);
+ oracle_uart_set_parity(port, 0);
+}
+fn ourParityOddThenOff() void {
+ const u = ours();
+ u.setParity(.odd);
+ u.setParity(.disable);
+}
+
+// -------------------------------------------------------------------------------------- loopback
+
+fn idfLoopOn() void {
+ oracle_uart_set_loop_back(port, 1);
+}
+fn ourLoopOn() void {
+ ours().setLoopback(true);
+}
+fn idfLoopOff() void {
+ oracle_uart_set_loop_back(port, 0);
+}
+fn ourLoopOff() void {
+ ours().setLoopback(false);
+}
+
+// ------------------------------------------------------------------------------ FIFO and commit
+
+fn idfTxFifoRst() void {
+ oracle_uart_txfifo_rst(port);
+}
+fn ourTxFifoRst() void {
+ ours().resetTxFifo();
+}
+fn idfRxFifoRst() void {
+ oracle_uart_rxfifo_rst(port);
+}
+fn ourRxFifoRst() void {
+ ours().resetRxFifo();
+}
+fn idfUpdate() void {
+ oracle_uart_update(port);
+}
+fn ourUpdate() void {
+ _ = ours().update();
+}
+
+// ----------------------------------------------------------------------------------- pin routing
+
+fn idfRouteTx() void {
+ oracle_uart_route_tx(port, route_pin);
+}
+fn ourRouteTx() void {
+ ours().routeTx(route_pin);
+}
+fn idfRouteRx() void {
+ oracle_uart_route_rx(port, route_pin);
+}
+fn ourRouteRx() void {
+ ours().routeRx(route_pin);
+}
diff --git a/src/oracle/uart_ref.c b/src/oracle/uart_ref.c
new file mode 100644
index 0000000..6476fd4
--- /dev/null
+++ b/src/oracle/uart_ref.c
@@ -0,0 +1,163 @@
+/* UART's reference implementation: ESP-IDF's own `uart_ll.h`, given external linkage.
+ *
+ * No logic here. Anything clever in this file would be a third implementation to doubt, and the
+ * whole point of the differential is that one side is unmodified IDF.
+ *
+ * Two IDF-specific notes:
+ *
+ * - Several of these LL functions are shadowed by a macro that references
+ * `__DECLARE_RCC_ATOMIC_ENV`, an identifier IDF never defines anywhere, so that an unguarded call
+ * fails to compile: HP_SYS_CLKRST's PERI_CLK_CTRL and SOC_CLK_CTRL registers are shared with
+ * unrelated peripherals and the only legal caller holds a spinlock. There is no FreeRTOS here and
+ * core 1 is held in reset at power-on, so declaring the name is as safe as the spinlock would be,
+ * and it is what IDF's own bootloader does (bootloader_console.c:53).
+ * - `uart_ll_set_sclk` and `uart_ll_set_baudrate` are *function-like macros* wrapping
+ * `uart_ll_set_sclk` / `_uart_ll_set_baudrate` (uart_ll.h:477-481, 578-586). Calling the
+ * underscored inner function directly would skip the very guard above, so these wrappers call
+ * through the macro. For `set_sclk` the macro and the function share a name, which works only
+ * because the macro is defined after the function.
+ */
+
+static int __DECLARE_RCC_ATOMIC_ENV __attribute__((unused));
+
+#include "hal/uart_ll.h"
+#include "soc/uart_struct.h"
+
+/* Nothing here touches UART0. Resetting UART0 clears UART_CLKDIV, and UART0 is this board's
+ * console: the output turns to garbage mid-character and the board takes a watchdog reset with
+ * nothing readable left to say why. Measured on this board. The suite drives UART1. */
+static inline uart_dev_t *dev(unsigned num)
+{
+ return UART_LL_GET_HW(num);
+}
+
+/* ---------------------------------------------------------------------- clocks, reset, baud */
+
+/* Only the operations uart_cases.zig actually pairs are wrapped. A wrapper with no case behind it
+ * is unreachable code that looks like coverage - and for `uart_ll_reset_register` in particular it
+ * would be a loaded gun, since the harness restores this block through its own reset-bit path and
+ * calling that function with num = 0 kills the console. */
+
+/* The whole point of the UART suite: IDF's baud-rate arithmetic, which picks an integer pre-divider
+ * and then a 12-bit divider with a 4-bit fraction, and commits through REG_UPDATE. Returns false on
+ * the rates it cannot represent - which is a real outcome, not an error path, since a 12-bit divider
+ * cannot reach every baud from every source clock. uart_ll.h:532-588. */
+int oracle_uart_set_baudrate(unsigned num, unsigned baud, unsigned sclk_freq)
+{
+ return _uart_ll_set_baudrate(UART_LL_GET_HW(num), baud, sclk_freq) ? 1 : 0;
+}
+
+void oracle_uart_enable_bus_clock(unsigned num, int enable)
+{
+ uart_ll_enable_bus_clock((uart_port_t)num, enable != 0);
+}
+
+void oracle_uart_sclk_enable(unsigned num)
+{
+ uart_ll_sclk_enable(dev(num));
+}
+
+
+/* `sel` is the soc_module_clk_t value, not the raw field encoding: UART_SCLK_XTAL,
+ * UART_SCLK_RTC, UART_SCLK_PLL_F80M. The mapping from those to the 2-bit field is the part of
+ * uart_ll_set_sclk under test. */
+/* The clock source, named on the C side rather than passed as a number.
+ *
+ * `UART_SCLK_XTAL` is an enumerator of `soc_module_clk_t`, not a small ordinal: clk_tree_defs.h:280
+ * defines it as SOC_MOD_CLK_XTAL, whose value is whatever position it happens to occupy in a
+ * chip-wide enum. Passing 0 from Zig - which is what a first version did - lands on an unmatched
+ * case in `_uart_ll_set_sclk`, whose default is HAL_ASSERT(false); compiled at assertion level 0
+ * that is `__builtin_unreachable()`, so the select is left at a value with no clock behind it and
+ * the very next `uart_ll_update` spins forever on a commit that cannot land. The harness reached the
+ * first UART case and went silent, which looks exactly like a crash.
+ *
+ * Keeping the enumerator on this side of the boundary removes the class of bug entirely. */
+void oracle_uart_set_sclk_xtal(unsigned num)
+{
+ uart_ll_set_sclk(UART_LL_GET_HW(num), UART_SCLK_XTAL);
+}
+
+void oracle_uart_set_sclk_pll(unsigned num)
+{
+ uart_ll_set_sclk(UART_LL_GET_HW(num), UART_SCLK_PLL_F80M);
+}
+
+void oracle_uart_set_sclk(unsigned num, unsigned sel)
+{
+ uart_ll_set_sclk(dev(num), (soc_module_clk_t)sel);
+}
+
+
+/* -------------------------------------------------------------------------------- data format */
+
+void oracle_uart_set_data_bit_num(unsigned num, unsigned bits)
+{
+ uart_ll_set_data_bit_num(dev(num), (uart_word_length_t)bits);
+}
+
+void oracle_uart_set_stop_bits(unsigned num, unsigned stop)
+{
+ uart_ll_set_stop_bits(dev(num), (uart_stop_bits_t)stop);
+}
+
+void oracle_uart_set_parity(unsigned num, unsigned parity)
+{
+ uart_ll_set_parity(dev(num), (uart_parity_t)parity);
+}
+
+/* ---------------------------------------------------------------------------------------- FIFO */
+
+void oracle_uart_txfifo_rst(unsigned num)
+{
+ uart_ll_txfifo_rst(dev(num));
+}
+
+void oracle_uart_rxfifo_rst(unsigned num)
+{
+ uart_ll_rxfifo_rst(dev(num));
+}
+
+
+/* ------------------------------------------------------------------------- loopback and update */
+
+void oracle_uart_set_loop_back(unsigned num, int enable)
+{
+ uart_ll_set_loop_back(dev(num), enable != 0);
+}
+
+void oracle_uart_update(unsigned num)
+{
+ uart_ll_update(dev(num));
+}
+
+/* ---------------------------------------------------------------------------------- pin routing */
+
+/* IDF routes UART pins through esp_rom_gpio_connect_*_signal / gpio_ll, not through uart_ll, so the
+ * reference for pin routing is the GPIO LL - the same functions gpio_ref.c wraps, called with this
+ * UART's signal indices. Kept here rather than in gpio_ref.c because the *signal index* is the part
+ * under test, and it belongs to the UART. */
+#include "hal/gpio_ll.h"
+#include "soc/gpio_struct.h"
+/* The signal indices themselves, which are not in any *_ll.h - and are the whole point of these two
+ * wrappers: the Zig side reads the same macros through translate-c. */
+#include "soc/gpio_sig_map.h"
+
+void oracle_uart_route_tx(unsigned num, unsigned pin)
+{
+ const unsigned sig[5] = {
+ UART0_TXD_PAD_OUT_IDX, UART1_TXD_PAD_OUT_IDX, UART2_TXD_PAD_OUT_IDX,
+ UART3_TXD_PAD_OUT_IDX, UART4_TXD_PAD_OUT_IDX,
+ };
+ gpio_ll_set_output_signal_matrix_source(&GPIO, pin, sig[num], false);
+ gpio_ll_set_output_enable_ctrl(&GPIO, pin, true, false);
+}
+
+void oracle_uart_route_rx(unsigned num, unsigned pin)
+{
+ const unsigned sig[5] = {
+ UART0_RXD_PAD_IN_IDX, UART1_RXD_PAD_IN_IDX, UART2_RXD_PAD_IN_IDX,
+ UART3_RXD_PAD_IN_IDX, UART4_RXD_PAD_IN_IDX,
+ };
+ gpio_ll_input_enable(&GPIO, pin);
+ gpio_ll_set_input_signal_matrix_source(&GPIO, sig[num], pin, false);
+}