summaryrefslogtreecommitdiff
path: root/src/oracle/ledc_cases.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/oracle/ledc_cases.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/oracle/ledc_cases.zig')
-rw-r--r--src/oracle/ledc_cases.zig513
1 files changed, 513 insertions, 0 deletions
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);
+}
+