velxio/frontend/src/simulation/Esp32C3Simulator.ts

1458 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Esp32C3Simulator — Browser-side ESP32-C3 emulator.
*
* Wraps RiscVCore (RV32IMC) with:
* - ESP32-C3 memory map: Flash IROM/DROM @ 0x42000000/0x3C000000,
* DRAM @ 0x3FC80000, IRAM @ 0x4037C000
* - UART0 MMIO @ 0x60000000 (serial I/O)
* - GPIO MMIO @ 0x60004000 (pin output via OUT/W1TS/W1TC registers)
* - 160 MHz clock, requestAnimationFrame execution loop
* - Same public interface as AVRSimulator / RiscVSimulator
*/
import { RiscVCore } from './RiscVCore';
import type { PinManager } from './PinManager';
import { hexToUint8Array } from '../utils/hexParser';
import { parseMergedFlashImage } from '../utils/esp32ImageParser';
// ── ESP32-C3 Memory Map ──────────────────────────────────────────────────────
const IROM_BASE = 0x42000000; // Flash instruction region (mapped via MMU)
const DROM_BASE = 0x3c000000; // Flash data region (read-only alias of same flash)
const DRAM_BASE = 0x3fc80000; // Data RAM
const IRAM_BASE = 0x4037c000; // Instruction RAM
const IROM_SIZE = 4 * 1024 * 1024; // 4 MB flash buffer
const DRAM_SIZE = 384 * 1024; // 384 KB DRAM
const IRAM_SIZE = 384 * 1024; // 384 KB IRAM
// ── UART0 @ 0x60000000 ──────────────────────────────────────────────────────
const UART0_BASE = 0x60000000;
const UART0_SIZE = 0x400;
const UART0_FIFO = 0x00; // write TX byte / read RX byte
const UART0_STATUS = 0x1c; // TXFIFO_CNT in bits [19:16] (0 = empty = ready)
// ── GPIO @ 0x60004000 ───────────────────────────────────────────────────────
const GPIO_BASE = 0x60004000;
const GPIO_SIZE = 0x200;
const GPIO_OUT = 0x04; // GPIO_OUT_REG — output value (read/write)
const GPIO_W1TS = 0x08; // GPIO_OUT_W1TS — set bits (write-only)
const GPIO_W1TC = 0x0c; // GPIO_OUT_W1TC — clear bits (write-only)
const GPIO_IN = 0x3c; // GPIO_IN_REG — input value (read-only)
const GPIO_ENABLE = 0x20; // GPIO_ENABLE_REG
// ── SYSTIMER @ 0x60023000 ────────────────────────────────────────────────────
// The SYSTIMER runs at 16 MHz (CPU_HZ / 10). FreeRTOS programs TARGET0 to
// fire every 1 ms (16 000 SYSTIMER ticks = 160 000 CPU cycles) and routes the
// alarm interrupt to CPU interrupt 1 via the interrupt matrix.
const SYSTIMER_BASE = 0x60023000;
const SYSTIMER_SIZE = 0x100;
// Register offsets (ESP32-C3 TRM)
const ST_INT_ENA = 0x04; // TARGET0/1/2 enable bits
const ST_INT_RAW = 0x08; // raw interrupt status
const ST_INT_CLR = 0x0c; // write-1-to-clear
const ST_INT_ST = 0x10; // masked status (RAW & ENA)
const ST_UNIT0_OP = 0x14; // write bit30 to snapshot counter
const ST_UNIT0_VAL_LO = 0x54; // snapshot value low 32 bits
const ST_UNIT0_VAL_HI = 0x58; // snapshot value high 32 bits
// ── SPI Flash Controllers ────────────────────────────────────────────────────
// SPI1 @ 0x60002000 — direct flash controller (boot-time flash access)
// SPI0 @ 0x60003000 — cache SPI controller (transparent flash cache)
// SPI_MEM_CMD_REG offset 0x00 bits [1731] are "write 1 to start, HW clears when done".
const SPI1_BASE = 0x60002000;
const SPI0_BASE = 0x60003000;
const SPI_SIZE = 0x200;
const SPI_CMD = 0x00; // SPI_MEM_CMD_REG — command trigger / status
// ── EXTMEM (cache controller) @ 0x600C4000 ──────────────────────────────────
// Manages ICache enable, invalidation, preload, and MMU configuration.
const EXTMEM_BASE = 0x600c4000;
const EXTMEM_SIZE = 0x1000;
// Key register offsets with "done" status bits that must read as 1:
const EXTMEM_ICACHE_SYNC_CTRL = 0x28; // bit1=SYNC_DONE
const EXTMEM_ICACHE_PRELOAD_CTRL = 0x34; // bit1=PRELOAD_DONE
const EXTMEM_ICACHE_AUTOLOAD_CTRL = 0x40; // bit3=AUTOLOAD_DONE
const EXTMEM_ICACHE_LOCK_CTRL = 0x1c; // bit2=LOCK_DONE
// ── Interrupt Matrix @ 0x600C2000 ─────────────────────────────────────────
// The ESP32-C3 interrupt matrix routes 62 peripheral interrupt sources to
// up to 31 CPU interrupt lines (line 0 = disabled).
const INTMATRIX_BASE = 0x600c2000;
const INTMATRIX_SIZE = 0x800;
// Register layout (offsets from INTMATRIX_BASE):
// 0x000-0x0F4 : 62 SOURCE_MAP registers (5-bit mapping: source → CPU line)
// 0x104 : INTR_STATUS (pending lines bitmap, read-only)
// 0x108 : CLOCK_GATE (clock gating enable)
// 0x118-0x194 : PRIORITY for lines 131 (4-bit each)
// 0x198 : THRESH (interrupt threshold, 4-bit)
// ── SYSTEM/CLK registers @ 0x600C0000 ─────────────────────────────────────
// Contains FROM_CPU_INTR software interrupt triggers and misc system config.
const SYSCON_BASE = 0x600c0000;
const SYSCON_SIZE = 0x800;
// ── Interrupt source numbers (from ESP-IDF soc/esp32c3/interrupts.h) ─────
const ETS_SYSTIMER_TARGET0_SRC = 37;
const ETS_FROM_CPU_INTR0_SRC = 28; // FROM_CPU_INTR0..3 → sources 28-31
// ── ESP32-C3 ROM stub @ 0x40000000 ──────────────────────────────────────────
// ROM lives at 0x40000000-0x4001FFFF. Without a ROM image every ROM call
// fetches 0x0000 → CPU executes reserved C.ADDI4SPN and loops at 0x0.
// Stub: return C.JR ra (0x8082) so any ROM call immediately returns.
// Little-endian: even byte = 0x82, odd byte = 0x80.
const ROM_BASE = 0x40000000;
const ROM_SIZE = 0x60000; // 0x40000000-0x4005FFFF (first ROM + margin)
const ROM2_BASE = 0x40800000;
const ROM2_SIZE = 0x20000; // 0x40800000-0x4081FFFF (second ROM region)
// ── Clock ───────────────────────────────────────────────────────────────────
const CPU_HZ = 160_000_000;
const CYCLES_PER_FRAME = Math.round(CPU_HZ / 60);
/** CPU cycles per FreeRTOS tick (1 ms at 160 MHz). */
const CYCLES_PER_TICK = 160_000;
export class Esp32C3Simulator {
// Opt into connectDigitalInputsToMcu: after every SPICE solve the connector
// thresholds each input pin's net voltage and pushes the logic level into the
// GPIO_IN register via setPinState(). So digitalRead() reflects the real
// wiring (a switch/button tied to 3V3 reads HIGH) instead of a hardcoded part
// seed. The connector skips MCU-driven output pins and floating (non-sourced)
// nets, so event-driven parts with no SPICE model keep driving their pins via
// the part layer. Mirrors AVRSimulator / RP2040Simulator.
readonly spiceDrivenInputs = true;
private core: RiscVCore;
private flash: Uint8Array;
private dram: Uint8Array;
private iram: Uint8Array;
private running = false;
private animFrameId = 0;
private rxFifo: number[] = [];
private gpioOut = 0;
private gpioIn = 0;
// SYSTIMER emulation state
private _stIntEna = 0; // ST_INT_ENA register
private _stIntRaw = 0; // ST_INT_RAW register (bit0 = TARGET0 fired)
// ── ROM binary data (loaded asynchronously from /boards/esp32c3-rom.bin) ──
private _romData: Uint8Array | null = null;
// ── Interrupt matrix state ────────────────────────────────────────────────
/** Source→CPU-line mapping (62 sources, each 5-bit → 0-31). */
private _intSrcMap = new Uint8Array(62);
/** CPU interrupt line enable bitmap (bit N = line N enabled). */
private _intLineEnable = 0;
/** Per-line priority (lines 131, 4-bit each). Index 0 unused. */
private _intLinePrio = new Uint8Array(32);
/** Interrupt threshold — only lines with priority > threshold can fire. */
private _intThreshold = 0;
/** Pending interrupt bitmap (set when source is active but can't fire). */
private _intPending = 0;
/** Current level of each interrupt source (1=asserted, 0=deasserted). */
private _intSrcActive = new Uint8Array(62);
/**
* Shared peripheral register file — echo-back map.
* Peripheral MMIO writes that aren't handled by specific logic are stored
* here keyed by word-aligned address so that subsequent reads return the
* last written value. This makes common "write → read-back → verify"
* patterns in the ESP-IDF boot succeed without dedicated stubs.
*/
private _periRegs = new Map<number, number>();
/** CPU ticks per µs — updated by ets_update_cpu_frequency(). */
private _ticksPerUs = 160;
public pinManager: PinManager;
public onSerialData: ((ch: string) => void) | null = null;
public onBaudRateChange: ((baud: number) => void) | null = null;
public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null;
constructor(pinManager: PinManager) {
this.pinManager = pinManager;
// Flash is the primary (fast-path) memory region
this.flash = new Uint8Array(IROM_SIZE);
this.dram = new Uint8Array(DRAM_SIZE);
this.iram = new Uint8Array(IRAM_SIZE);
this.core = new RiscVCore(this.flash, IROM_BASE);
// DROM — read-only alias of the same flash buffer at a different virtual address
const flash = this.flash;
this.core.addMmio(
DROM_BASE,
IROM_SIZE,
(addr) => flash[addr - DROM_BASE] ?? 0,
() => {},
);
// DRAM (384 KB)
const dram = this.dram;
this.core.addMmio(
DRAM_BASE,
DRAM_SIZE,
(addr) => dram[addr - DRAM_BASE],
(addr, val) => {
dram[addr - DRAM_BASE] = val;
},
);
// IRAM (384 KB)
const iram = this.iram;
this.core.addMmio(
IRAM_BASE,
IRAM_SIZE,
(addr) => iram[addr - IRAM_BASE],
(addr, val) => {
iram[addr - IRAM_BASE] = val;
},
);
// Broad catch-all for all peripheral space must be registered FIRST (largest
// region) so that narrower, more specific handlers registered afterwards win
// via mmioFor's "smallest size wins" rule.
this._registerPeripheralCatchAll();
this._registerUart0();
this._registerGpio();
this._registerSysTimer();
this._registerIntMatrix();
this._registerSysCon();
this._registerRtcCntl();
// Timer Groups — stub RTCCALICFG1.cal_done for all known base addresses
// so rtc_clk_cal_internal() poll loop exits immediately.
this._registerTimerGroup(0x60026000); // TIMG0 (ESP-IDF v5 / arduino-esp32 3.x)
this._registerTimerGroup(0x60027000); // TIMG1
this._registerTimerGroup(0x6001f000); // TIMG0 alternative (older ESP-IDF)
this._registerTimerGroup(0x60020000); // TIMG1 alternative
this._registerSpiFlash(SPI1_BASE); // SPI1 — direct flash controller
this._registerSpiFlash(SPI0_BASE); // SPI0 — cache SPI controller
this._registerExtMem();
this._registerRomStub();
this._registerRomStub2();
// Wire MIE transition callback — when firmware re-enables interrupts,
// scan the interrupt matrix for pending sources and inject them.
this.core.onMieEnabled = () => this._onMieEnabled();
// NOTE: Real ROM binary loading disabled — the ROM code accesses many
// peripherals we don't fully emulate, causing the CPU to jump to invalid
// addresses. C.RET stubs returning a0=0 are sufficient for now.
// this._loadRom();
this.core.reset(IROM_BASE);
// Initialize SP to top of DRAM — MUST be after reset() which zeroes all regs
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
// ── MMIO registration ──────────────────────────────────────────────────────
private _registerUart0(): void {
this.core.addMmio(
UART0_BASE,
UART0_SIZE,
(addr) => {
const off = addr - UART0_BASE;
if (off === UART0_FIFO) return this.rxFifo.length > 0 ? this.rxFifo.shift()! & 0xff : 0;
if (off === UART0_STATUS) return 0; // TXFIFO always empty = ready to accept data
return 0;
},
(addr, val) => {
if (addr - UART0_BASE === UART0_FIFO) {
this.onSerialData?.(String.fromCharCode(val & 0xff));
}
},
);
}
private _registerGpio(): void {
this.core.addMmio(
GPIO_BASE,
GPIO_SIZE,
(addr) => {
const off = (addr - GPIO_BASE) & ~3; // word-align for register lookup
const byteIdx = (addr - GPIO_BASE) & 3;
if (off === GPIO_OUT) return (this.gpioOut >> (byteIdx * 8)) & 0xff;
if (off === GPIO_IN) return (this.gpioIn >> (byteIdx * 8)) & 0xff;
if (off === GPIO_ENABLE) return 0xff;
return 0;
},
(addr, val) => {
const off = (addr - GPIO_BASE) & ~3;
const byteIdx = (addr - GPIO_BASE) & 3;
const shift = byteIdx * 8;
const byteMask = 0xff << shift;
const prev = this.gpioOut;
if (off === GPIO_W1TS) {
// Set bits — each byte write sets corresponding bits
this.gpioOut |= (val & 0xff) << shift;
} else if (off === GPIO_W1TC) {
// Clear bits
this.gpioOut &= ~((val & 0xff) << shift);
} else if (off === GPIO_OUT) {
// Direct write — reconstruct 32-bit value byte by byte
this.gpioOut = (this.gpioOut & ~byteMask) | ((val & 0xff) << shift);
}
const changed = prev ^ this.gpioOut;
if (changed) {
const timeMs = (this.core.cycles / CPU_HZ) * 1000;
for (let bit = 0; bit < 22; bit++) {
// ESP32-C3 has GPIO0GPIO21
if (changed & (1 << bit)) {
const state = !!(this.gpioOut & (1 << bit));
console.log(
`[ESP32-C3] GPIO${bit}${state ? 'HIGH' : 'LOW'} @ ${timeMs.toFixed(1)}ms`,
);
this.onPinChangeWithTime?.(bit, state, timeMs);
this.pinManager.setPinState(bit, state, 'mcu');
}
}
}
},
);
}
private _registerSysTimer(): void {
const peri = this._periRegs;
this.core.addMmio(
SYSTIMER_BASE,
SYSTIMER_SIZE,
(addr) => {
const off = addr - SYSTIMER_BASE;
const wordOff = off & ~3;
const byteIdx = off & 3;
let word = 0;
let handled = true;
switch (wordOff) {
case ST_INT_ENA:
word = this._stIntEna;
break;
case ST_INT_RAW:
word = this._stIntRaw;
break;
case ST_INT_ST:
word = this._stIntRaw & this._stIntEna;
break;
case ST_UNIT0_OP:
word = 1 << 29;
break; // VALID bit always set
case ST_UNIT0_VAL_LO:
word = (this.core.cycles / 10) >>> 0;
break;
case ST_UNIT0_VAL_HI:
word = 0;
break;
default:
handled = false;
break;
}
if (!handled) {
// Echo last written value for unknown offsets
const wordAddr = addr & ~3;
word = peri.get(wordAddr) ?? 0;
}
return (word >> (byteIdx * 8)) & 0xff;
},
(addr, val) => {
const off = addr - SYSTIMER_BASE;
const wordOff = off & ~3;
const shift = (off & 3) * 8;
switch (wordOff) {
case ST_INT_ENA:
this._stIntEna = (this._stIntEna & ~(0xff << shift)) | ((val & 0xff) << shift);
break;
case ST_INT_CLR:
this._stIntRaw &= ~((val & 0xff) << shift);
// If TARGET0 was cleared, deassert the interrupt source
if (!(this._stIntRaw & 1)) {
this._lowerIntSource(ETS_SYSTIMER_TARGET0_SRC);
}
break;
default: {
// Echo-back: store the written value
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
break;
}
}
},
);
}
// ── Async ROM binary loader ──────────────────────────────────────────────
// @ts-expect-error kept for future use when more peripherals are emulated
private async _loadRom(): Promise<void> {
try {
const resp = await fetch('/boards/esp32c3-rom.bin');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const buf = await resp.arrayBuffer();
this._romData = new Uint8Array(buf);
console.log(`[ESP32-C3] ROM binary loaded (${this._romData.length} bytes)`);
} catch (e) {
console.warn('[ESP32-C3] Failed to load ROM binary, using C.RET stub:', e);
}
}
// ── Interrupt matrix ───────────────────────────────────────────────────────
/**
* Interrupt matrix (0x600C2000).
*
* 62 SOURCE_MAP registers route peripheral interrupt sources to CPU lines.
* The ENABLE bitmap, per-line PRIORITY, and THRESHOLD control which
* interrupts can fire. Reads/writes are echo-backed via _periRegs and
* internal state is updated on every write.
*/
private _registerIntMatrix(): void {
const peri = this._periRegs;
const BASE = INTMATRIX_BASE;
this.core.addMmio(
BASE,
INTMATRIX_SIZE,
(addr) => {
const off = (addr - BASE) & ~3;
const byteIdx = (addr - BASE) & 3;
let word = 0;
if (off <= 0x0f8) {
// SOURCE_MAP[0..62] (offsets 0x000-0x0F8)
const src = off >> 2;
word = src < 62 ? this._intSrcMap[src] & 0x1f : 0;
} else if (off === 0x104) {
// CPU_INT_ENABLE — which CPU interrupt lines are enabled (R/W)
word = this._intLineEnable;
} else if (off === 0x108) {
// CPU_INT_TYPE — edge/level per line (echo-back)
word = peri.get(addr & ~3) ?? 0;
} else if (off === 0x10c) {
// CPU_INT_EIP_STATUS — which lines have pending interrupts (read-only)
word = this._intPending;
} else if (off >= 0x114 && off <= 0x190) {
// CPU_INT_PRI_0..31 (offsets 0x114 + line*4)
const line = (off - 0x114) >> 2;
word = line < 32 ? this._intLinePrio[line] : 0;
} else if (off === 0x194) {
// CPU_INT_THRESH
word = this._intThreshold;
} else {
word = peri.get(addr & ~3) ?? 0;
}
return (word >>> (byteIdx * 8)) & 0xff;
},
(addr, val) => {
// Always store for echo-back
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
const newWord = (prev & ~(0xff << shift)) | ((val & 0xff) << shift);
peri.set(wordAddr, newWord);
// Update internal state from accumulated word
const off = wordAddr - BASE;
if (off <= 0x0f8) {
const src = off >> 2;
if (src < 62) {
this._intSrcMap[src] = newWord & 0x1f;
}
} else if (off === 0x104) {
this._intLineEnable = newWord;
} else if (off >= 0x114 && off <= 0x190) {
const line = (off - 0x114) >> 2;
if (line < 32) this._intLinePrio[line] = newWord & 0xf;
} else if (off === 0x194) {
this._intThreshold = newWord & 0xf;
}
},
);
}
/**
* SYSTEM/CLK registers (0x600C0000).
*
* Provides FROM_CPU_INTR software interrupt triggers (FreeRTOS uses these
* for cross-core signalling / context switch on single-core C3) and a
* random-number register.
*/
private _registerSysCon(): void {
const peri = this._periRegs;
const BASE = SYSCON_BASE;
this.core.addMmio(
BASE,
SYSCON_SIZE,
(addr) => {
const wordAddr = addr & ~3;
const byteIdx = (addr - BASE) & 3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> (byteIdx * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
const newWord = (prev & ~(0xff << shift)) | ((val & 0xff) << shift);
peri.set(wordAddr, newWord);
// FROM_CPU_INTR: offsets 0x028, 0x02C, 0x030, 0x034
const off = wordAddr - BASE;
if (off >= 0x028 && off <= 0x034) {
const idx = (off - 0x028) >> 2; // 03
const src = ETS_FROM_CPU_INTR0_SRC + idx; // sources 2831
if (newWord & 1) this._raiseIntSource(src);
else this._lowerIntSource(src);
}
},
);
}
// ── Interrupt matrix dispatch helpers ──────────────────────────────────────
/**
* Assert a peripheral interrupt source. Looks up its CPU line via the
* source-map and either fires the interrupt (if MIE is set and priority
* meets threshold) or marks it pending.
*/
private _raiseIntSource(src: number): void {
if (src >= 62) return;
this._intSrcActive[src] = 1;
const line = this._intSrcMap[src] & 0x1f;
if (line === 0) return; // line 0 = disabled / not routed
// Mark pending for this line
this._intPending |= 1 << line;
// Can we deliver right now?
if (!(this._intLineEnable & (1 << line))) return;
const prio = this._intLinePrio[line];
if (prio <= this._intThreshold) return;
if (!(this.core.mstatusVal & 0x8)) return; // MIE not set — stay pending
this._intPending &= ~(1 << line);
this.core.triggerInterrupt(0x80000000 | line);
}
/** Deassert a peripheral interrupt source and clear its pending state. */
private _lowerIntSource(src: number): void {
if (src >= 62) return;
this._intSrcActive[src] = 0;
const line = this._intSrcMap[src] & 0x1f;
if (line === 0) return;
// Check if any OTHER active source also maps to this line
let stillActive = false;
for (let s = 0; s < 62; s++) {
if (s !== src && this._intSrcActive[s] && (this._intSrcMap[s] & 0x1f) === line) {
stillActive = true;
break;
}
}
if (!stillActive) this._intPending &= ~(1 << line);
}
/**
* Scan pending interrupts and deliver the highest-priority one.
* Called when mstatus.MIE transitions 0→1 (MRET or CSR write).
*/
private _onMieEnabled(): void {
if (this._intPending === 0) return;
let bestLine = 0;
let bestPrio = 0;
for (let line = 1; line < 32; line++) {
if (!(this._intPending & (1 << line))) continue;
if (!(this._intLineEnable & (1 << line))) continue;
const prio = this._intLinePrio[line];
if (prio > this._intThreshold && prio > bestPrio) {
bestPrio = prio;
bestLine = line;
}
}
if (bestLine > 0) {
this._intPending &= ~(1 << bestLine);
this.core.triggerInterrupt(0x80000000 | bestLine);
}
}
/**
* ROM region (0x40000000-0x4005FFFF) — when the CPU fetches an instruction
* from a known ROM function address, we emulate it natively in JavaScript
* (memset, memcpy, __udivdi3, ets_delay_us, etc.), set the return value
* in a0/a1, and serve C.RET (0x8082) so the CPU returns to the caller.
* Unknown functions still get a0=0 + C.RET.
*/
private _registerRomStub(): void {
const core = this.core;
this.core.addMmio(
ROM_BASE,
ROM_SIZE,
(addr) => {
// If we have the real ROM binary, serve it
if (this._romData) {
const off = (addr >>> 0) - ROM_BASE;
if (off < this._romData.length) return this._romData[off];
}
// Detect instruction fetch and emulate known ROM functions
if ((addr & 1) === 0 && addr >>> 0 === core.pc >>> 0) {
this._emulateRomFunction(addr >>> 0);
}
return (addr & 1) === 0 ? 0x82 : 0x80;
},
() => {},
);
}
/** Second ROM region (0x40800000) — same emulation with fallback a0=0. */
private _registerRomStub2(): void {
const core = this.core;
this.core.addMmio(
ROM2_BASE,
ROM2_SIZE,
(addr) => {
if ((addr & 1) === 0 && addr >>> 0 === core.pc >>> 0) {
this._emulateRomFunction(addr >>> 0);
}
return (addr & 1) === 0 ? 0x82 : 0x80;
},
() => {},
);
}
// ── ROM function emulation ─────────────────────────────────────────────────
//
// The ESP32-C3 firmware links common C library functions (memset, memcpy,
// __udivdi3, …) and helper functions (ets_delay_us, rtc_get_reset_reason, …)
// to addresses in the ROM region (0x40000000+). Since we don't run the real
// ROM binary, we emulate these functions natively in JavaScript by reading
// arguments from registers a0a7, performing the operation on emulated
// memory, and writing the result back to a0(/a1). The caller then hits our
// C.RET (0x8082) stub and returns normally.
//
// Address mappings from esp32c3.rom.ld / esp32c3.rom.libgcc.ld:
private _emulateRomFunction(addr: number): void {
const r = this.core.regs;
const c = this.core;
switch (addr) {
// ── C library functions ──────────────────────────────────────────
case 0x40000354: {
// memset(dest, val, n) → dest
const dest = r[10] >>> 0;
const val = r[11] & 0xff;
const n = r[12] >>> 0;
const safeN = Math.min(n, 0x100000); // cap at 1 MB safety
for (let i = 0; i < safeN; i++) c.writeByte(dest + i, val);
r[10] = dest | 0;
break;
}
case 0x40000358: {
// memcpy(dest, src, n) → dest
const dest = r[10] >>> 0;
const src = r[11] >>> 0;
const n = r[12] >>> 0;
const safeN = Math.min(n, 0x100000);
// Copy forward (like real memcpy, undefined for overlap)
for (let i = 0; i < safeN; i++) c.writeByte(dest + i, c.readByte(src + i));
r[10] = dest | 0;
break;
}
case 0x4000035c: {
// memmove(dest, src, n) → dest
const dest = r[10] >>> 0;
const src = r[11] >>> 0;
const n = r[12] >>> 0;
const safeN = Math.min(n, 0x100000);
if (dest < src) {
for (let i = 0; i < safeN; i++) c.writeByte(dest + i, c.readByte(src + i));
} else {
for (let i = safeN - 1; i >= 0; i--) c.writeByte(dest + i, c.readByte(src + i));
}
r[10] = dest | 0;
break;
}
case 0x40000360: {
// memcmp(a, b, n) → int
const a = r[10] >>> 0;
const b = r[11] >>> 0;
const n = r[12] >>> 0;
let result = 0;
for (let i = 0; i < n; i++) {
const diff = c.readByte(a + i) - c.readByte(b + i);
if (diff !== 0) {
result = diff > 0 ? 1 : -1;
break;
}
}
r[10] = result;
break;
}
case 0x40000364: {
// strcpy(dest, src) → dest
const dest = r[10] >>> 0;
const src = r[11] >>> 0;
let i = 0;
while (i < 0x100000) {
const ch = c.readByte(src + i);
c.writeByte(dest + i, ch);
if (ch === 0) break;
i++;
}
r[10] = dest | 0;
break;
}
case 0x4000036c: {
// strcmp(a, b) → int
const a = r[10] >>> 0;
const b = r[11] >>> 0;
let result = 0;
for (let i = 0; i < 0x100000; i++) {
const ca = c.readByte(a + i);
const cb = c.readByte(b + i);
if (ca !== cb) {
result = ca - cb;
break;
}
if (ca === 0) break;
}
r[10] = result;
break;
}
case 0x40000374: {
// strlen(s) → size_t
const s = r[10] >>> 0;
let len = 0;
while (len < 0x100000 && c.readByte(s + len) !== 0) len++;
r[10] = len;
break;
}
case 0x4000037c: {
// bzero(dest, n)
const dest = r[10] >>> 0;
const n = r[11] >>> 0;
const safeN = Math.min(n, 0x100000);
for (let i = 0; i < safeN; i++) c.writeByte(dest + i, 0);
break;
}
// ── libgcc 64-bit integer math ───────────────────────────────────
case 0x400008ac: {
// __udivdi3(a, b) → a / b (unsigned 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const bLo = r[12] >>> 0,
bHi = r[13] >>> 0;
const a = BigInt(aLo) | (BigInt(aHi) << 32n);
const b = BigInt(bLo) | (BigInt(bHi) << 32n);
const q = b !== 0n ? a / b : 0n;
r[10] = Number(q & 0xffffffffn) | 0;
r[11] = Number((q >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x400008b0: {
// __udivmoddi4(a, b, *rem) → a / b, *rem = a % b
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const bLo = r[12] >>> 0,
bHi = r[13] >>> 0;
const remPtr = r[14] >>> 0; // a4
const a = BigInt(aLo) | (BigInt(aHi) << 32n);
const b = BigInt(bLo) | (BigInt(bHi) << 32n);
const q = b !== 0n ? a / b : 0n;
const rem = b !== 0n ? a % b : 0n;
r[10] = Number(q & 0xffffffffn) | 0;
r[11] = Number((q >> 32n) & 0xffffffffn) | 0;
if (remPtr !== 0) {
c.writeWord(remPtr, Number(rem & 0xffffffffn));
c.writeWord(remPtr + 4, Number((rem >> 32n) & 0xffffffffn));
}
break;
}
case 0x400008b4: {
// __udivsi3(a, b) → a / b (unsigned 32-bit)
const a = r[10] >>> 0,
b = r[11] >>> 0;
r[10] = b !== 0 ? (a / b) >>> 0 : 0;
break;
}
case 0x400008bc: {
// __umoddi3(a, b) → a % b (unsigned 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const bLo = r[12] >>> 0,
bHi = r[13] >>> 0;
const a = BigInt(aLo) | (BigInt(aHi) << 32n);
const b = BigInt(bLo) | (BigInt(bHi) << 32n);
const rem = b !== 0n ? a % b : 0n;
r[10] = Number(rem & 0xffffffffn) | 0;
r[11] = Number((rem >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x400008c0: {
// __umodsi3(a, b) → a % b (unsigned 32-bit)
const a = r[10] >>> 0,
b = r[11] >>> 0;
r[10] = b !== 0 ? (a % b) >>> 0 : 0;
break;
}
case 0x400007b4: {
// __divdi3(a, b) → a / b (signed 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] | 0;
const bLo = r[12] >>> 0,
bHi = r[13] | 0;
const a = BigInt.asIntN(64, BigInt(aLo) | (BigInt(aHi) << 32n));
const b = BigInt.asIntN(64, BigInt(bLo) | (BigInt(bHi) << 32n));
const q = b !== 0n ? a / b : 0n;
const qu = BigInt.asUintN(64, q);
r[10] = Number(qu & 0xffffffffn) | 0;
r[11] = Number((qu >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x400007c0: {
// __divsi3(a, b) → a / b (signed 32-bit)
const a = r[10] | 0,
b = r[11] | 0;
r[10] = b !== 0 ? (a / b) | 0 : 0;
break;
}
case 0x4000083c: {
// __moddi3(a, b) → a % b (signed 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] | 0;
const bLo = r[12] >>> 0,
bHi = r[13] | 0;
const a = BigInt.asIntN(64, BigInt(aLo) | (BigInt(aHi) << 32n));
const b = BigInt.asIntN(64, BigInt(bLo) | (BigInt(bHi) << 32n));
const rem = b !== 0n ? a % b : 0n;
const remu = BigInt.asUintN(64, rem);
r[10] = Number(remu & 0xffffffffn) | 0;
r[11] = Number((remu >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x40000840: {
// __modsi3(a, b) → a % b (signed 32-bit)
const a = r[10] | 0,
b = r[11] | 0;
r[10] = b !== 0 ? (a % b) | 0 : 0;
break;
}
case 0x4000084c: {
// __muldi3(a, b) → a * b (64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const bLo = r[12] >>> 0,
bHi = r[13] >>> 0;
const a = BigInt(aLo) | (BigInt(aHi) << 32n);
const b = BigInt(bLo) | (BigInt(bHi) << 32n);
const p = BigInt.asUintN(64, a * b);
r[10] = Number(p & 0xffffffffn) | 0;
r[11] = Number((p >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x40000858: {
// __mulsi3(a, b) → a * b (32-bit)
r[10] = Math.imul(r[10], r[11]);
break;
}
// ── libgcc shift / bit operations ────────────────────────────────
case 0x4000077c: {
// __ashldi3(a, shift) → a << shift (64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt(aLo) | (BigInt(aHi) << 32n);
const res = BigInt.asUintN(64, val << BigInt(shift));
r[10] = Number(res & 0xffffffffn) | 0;
r[11] = Number((res >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x40000780: {
// __ashrdi3(a, shift) → a >> shift (signed/arithmetic 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt.asIntN(64, BigInt(aLo) | (BigInt(aHi) << 32n));
const res = BigInt.asUintN(64, val >> BigInt(shift));
r[10] = Number(res & 0xffffffffn) | 0;
r[11] = Number((res >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x40000830: {
// __lshrdi3(a, shift) → a >>> shift (unsigned/logical 64-bit)
const aLo = r[10] >>> 0,
aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt(aLo) | (BigInt(aHi) << 32n);
const res = val >> BigInt(shift); // positive BigInt → logical shift
r[10] = Number(res & 0xffffffffn) | 0;
r[11] = Number((res >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x4000079c: {
// __clzsi2(a) → count leading zeros (32-bit)
r[10] = Math.clz32(r[10] >>> 0);
break;
}
case 0x40000798: {
// __clzdi2(a) → count leading zeros (64-bit)
const lo = r[10] >>> 0,
hi = r[11] >>> 0;
r[10] = hi !== 0 ? Math.clz32(hi) : 32 + Math.clz32(lo);
break;
}
case 0x400007a8: {
// __ctzsi2(a) → count trailing zeros (32-bit)
const v = r[10] >>> 0;
r[10] = v === 0 ? 32 : 31 - Math.clz32(v & -v);
break;
}
case 0x400007a4: {
// __ctzdi2(a) → count trailing zeros (64-bit)
const lo = r[10] >>> 0,
hi = r[11] >>> 0;
if (lo !== 0) {
r[10] = 31 - Math.clz32(lo & -lo);
} else if (hi !== 0) {
r[10] = 32 + (31 - Math.clz32(hi & -hi));
} else {
r[10] = 64;
}
break;
}
case 0x4000086c: {
// __negdi2(a) → -a (64-bit)
const lo = r[10] >>> 0,
hi = r[11] >>> 0;
const val = BigInt(lo) | (BigInt(hi) << 32n);
const neg = BigInt.asUintN(64, -val);
r[10] = Number(neg & 0xffffffffn) | 0;
r[11] = Number((neg >> 32n) & 0xffffffffn) | 0;
break;
}
case 0x400007d0: {
// __ffsdi2(a) → find first set bit (64-bit), 0 if none
const lo = r[10] >>> 0,
hi = r[11] >>> 0;
if (lo !== 0) {
r[10] = 31 - Math.clz32(lo & -lo) + 1;
} else if (hi !== 0) {
r[10] = 32 + (31 - Math.clz32(hi & -hi)) + 1;
} else {
r[10] = 0;
}
break;
}
case 0x400007d4: {
// __ffssi2(a) → find first set bit (32-bit), 0 if none
const v = r[10] >>> 0;
r[10] = v === 0 ? 0 : 31 - Math.clz32(v & -v) + 1;
break;
}
case 0x40000784: {
// __bswapdi2(a) → byte-swap 64-bit
const lo = r[10] >>> 0,
hi = r[11] >>> 0;
const swapLo =
((lo >>> 24) | ((lo >>> 8) & 0xff00) | ((lo << 8) & 0xff0000) | (lo << 24)) >>> 0;
const swapHi =
((hi >>> 24) | ((hi >>> 8) & 0xff00) | ((hi << 8) & 0xff0000) | (hi << 24)) >>> 0;
r[10] = swapHi | 0; // swapped hi becomes new lo
r[11] = swapLo | 0; // swapped lo becomes new hi
break;
}
case 0x40000788: {
// __bswapsi2(a) → byte-swap 32-bit
const v = r[10] >>> 0;
r[10] = (v >>> 24) | ((v >>> 8) & 0xff00) | ((v << 8) & 0xff0000) | (v << 24) | 0;
break;
}
case 0x400007a0: {
// __cmpdi2(a, b) → 0 if a<b, 1 if a==b, 2 if a>b (signed 64-bit)
const a = BigInt.asIntN(64, BigInt(r[10] >>> 0) | (BigInt(r[11]) << 32n));
const b = BigInt.asIntN(64, BigInt(r[12] >>> 0) | (BigInt(r[13]) << 32n));
r[10] = a < b ? 0 : a === b ? 1 : 2;
break;
}
case 0x400008a8: {
// __ucmpdi2(a, b) → 0 if a<b, 1 if a==b, 2 if a>b (unsigned 64-bit)
const a = BigInt(r[10] >>> 0) | (BigInt(r[11] >>> 0) << 32n);
const b = BigInt(r[12] >>> 0) | (BigInt(r[13] >>> 0) << 32n);
r[10] = a < b ? 0 : a === b ? 1 : 2;
break;
}
case 0x40000764: {
// __absvdi2(a) → |a| (signed 64-bit, aborts on overflow)
const val = BigInt.asIntN(64, BigInt(r[10] >>> 0) | (BigInt(r[11]) << 32n));
const abs = val < 0n ? BigInt.asUintN(64, -val) : BigInt.asUintN(64, val);
r[10] = Number(abs & 0xffffffffn) | 0;
r[11] = Number((abs >> 32n) & 0xffffffffn) | 0;
break;
}
// ── ESP-IDF ROM helpers ──────────────────────────────────────────
case 0x40000018: {
// rtc_get_reset_reason() → 1 (POWERON_RESET)
r[10] = 1;
break;
}
case 0x40000050: {
// ets_delay_us(us) → void
// Burn the equivalent number of CPU cycles so timers advance
const us = r[10] >>> 0;
const burnCycles = Math.min(us * this._ticksPerUs, 1_000_000);
this.core.cycles += burnCycles;
r[10] = 0;
break;
}
case 0x40000548: {
// Cache_Set_IDROM_MMU_Size(...) → 0 (success)
r[10] = 0;
break;
}
case 0x40001960: {
// rom_i2c_writeReg_Mask(...) → 0 (success)
r[10] = 0;
break;
}
case 0x4000195c: {
// rom_i2c_writeReg(...) → 0 (success)
r[10] = 0;
break;
}
case 0x40000588: {
// ets_update_cpu_frequency(ticks_per_us)
// Store value so our ets_delay_us can use the right multiplier.
// Firmware calls this with e.g. 40 or 160.
this._ticksPerUs = r[10] >>> 0;
r[10] = 0;
break;
}
case 0x40000084: {
// uart_tx_wait_idle(uart_num) — no-op
r[10] = 0;
break;
}
case 0x400005f4: {
// intr_matrix_set(cpu_no, model_num, intr_num)
// Maps peripheral interrupt source a1 to CPU interrupt line a2.
// This directly programs the interrupt matrix hardware.
const source = r[11] >>> 0;
const line = r[12] & 0x1f;
if (source < 62) {
this._intSrcMap[source] = line;
// Also store in the MMIO echo-back register so firmware reads work
this._periRegs.set(INTMATRIX_BASE + source * 4, line);
}
break;
}
default: {
// Unknown ROM function — return a0=0 (ESP_OK for esp_err_t functions)
r[10] = 0;
break;
}
}
}
/**
* Timer Group stub (TIMG0 / TIMG1).
*
* Critical register: RTCCALICFG1 at offset 0x6C (confirmed from qemu-lcgamboa
* esp32c3_timg.h — offset 0x48 is TIMG_WDTCONFIG0, not the calibration result).
* Bit 31 = TIMG_RTC_CALI_DONE — must read as 1 or rtc_clk_cal_internal()
* spins forever waiting for calibration to complete.
* Bits [30:7] = cal_value — must be non-zero or the outer retry loop
* in esp_rtc_clk_init() keeps calling rtc_clk_cal() forever.
*
* Called for all known TIMG0/TIMG1 base addresses across ESP-IDF versions.
*/
private _registerTimerGroup(base: number): void {
const peri = this._periRegs;
this.core.addMmio(
base,
0x100,
(addr) => {
const off = addr - base;
const wOff = off & ~3;
if (wOff === 0x68) {
// TIMG_RTCCALICFG: bit15=TIMG_RTC_CALI_RDY=1 — calibration instantly done
// Also set bit31 (start bit echo) which some versions check
const word = (1 << 15) | (1 << 31);
return (word >>> ((off & 3) * 8)) & 0xff;
}
if (wOff === 0x6c) {
// TIMG_RTCCALICFG1: bits[31:7]=rtc_cali_value — non-zero so outer retry exits
const word = (136533 << 7) >>> 0; // typical 150kHz RTC vs 40MHz XTAL
return (word >>> ((off & 3) * 8)) & 0xff;
}
if (wOff === 0x80) {
// TIMG_RTCCALICFG2 (ESP-IDF v5): bit31=timeout(0), bits[24:7]=cali_value
// ESP-IDF v5 reads result HERE instead of RTCCALICFG1.
// Must be non-zero or rtc_clk_cal() retries forever.
const word = (136533 << 7) >>> 0;
return (word >>> ((off & 3) * 8)) & 0xff;
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
},
);
}
/**
* SPI flash controller stub (SPI0 / SPI1).
*
* SPI_MEM_CMD_REG (offset 0x00) bits [1731] are "write 1 to start operation,
* hardware clears when done". The firmware polls these bits after triggering
* flash reads, writes, erases, etc. We autoclear them so every flash
* operation appears to complete instantly.
*
* Other registers use echoback so configuration writes can be read back.
*/
private _registerSpiFlash(base: number): void {
const peri = this._periRegs;
this.core.addMmio(
base,
SPI_SIZE,
(addr) => {
const off = addr - base;
const wordOff = off & ~3;
if (wordOff === SPI_CMD) {
// Always return 0 for CMD register — all operations are "done"
return 0;
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
},
);
}
/**
* EXTMEM cache controller stub (0x600C4000).
*
* The ESP-IDF boot enables ICache, then triggers cache invalidation / sync /
* preload operations and polls "done" bits. We return all "done" bits as 1
* so these operations appear to complete instantly.
*/
private _registerExtMem(): void {
const peri = this._periRegs;
this.core.addMmio(
EXTMEM_BASE,
EXTMEM_SIZE,
(addr) => {
const off = addr - EXTMEM_BASE;
const wordOff = off & ~3;
// Return "done" bits for operations that the boot polls:
let override: number | null = null;
switch (wordOff) {
case EXTMEM_ICACHE_SYNC_CTRL:
override = 1 << 1;
break; // SYNC_DONE
case EXTMEM_ICACHE_PRELOAD_CTRL:
override = 1 << 1;
break; // PRELOAD_DONE
case EXTMEM_ICACHE_AUTOLOAD_CTRL:
override = 1 << 3;
break; // AUTOLOAD_DONE
case EXTMEM_ICACHE_LOCK_CTRL:
override = 1 << 2;
break; // LOCK_DONE
}
if (override !== null) {
// Merge override bits with any written value so enable bits are preserved
const wordAddr = addr & ~3;
const word = (peri.get(wordAddr) ?? 0) | override;
return (word >>> ((addr & 3) * 8)) & 0xff;
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
},
);
}
/**
* Broad catch-all for the entire ESP32-C3 peripheral address space
* (0x600000000x6FFFFFFF).
*
* Writes are stored in _periRegs so that the firmware's common
* "write config → read back → verify" pattern works for any peripheral
* register we haven't stubbed explicitly. All narrower, more specific
* handlers (UART0, GPIO, SYSTIMER, INTC, RTC_CNTL …) have smaller MMIO
* sizes and therefore take priority via mmioFor's "smallest-size-wins" rule.
*/
private _registerPeripheralCatchAll(): void {
const peri = this._periRegs;
this.core.addMmio(
0x60000000,
0x10000000,
(addr) => {
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
},
);
}
/**
* RTC_CNTL peripheral stub (0x60008000, 4 KB).
*
* Critical register: TIME_UPDATE_REG at offset 0x70 (address 0x60008070).
* Bit 30 = TIME_VALID — must read as 1 or the `rtc_clk_cal()` loop in
* esp-idf never exits and MIE is never enabled (FreeRTOS scheduler stalls).
* Also covers the eFUSE block at 0x60008800 (offset 0x800) — returns 0 for
* all eFuse words (chip-revision 0 / all features disabled = safe defaults).
*/
private _registerRtcCntl(): void {
const RTC_BASE = 0x60008000;
const peri = this._periRegs;
this.core.addMmio(
RTC_BASE,
0x1000,
(addr) => {
const off = addr - RTC_BASE;
const wordOff = off & ~3;
// offset 0x70 (RTC_CLK_CONF): TIME_VALID (bit 30) = 1 so rtc_clk_cal() exits.
// offset 0x38 (RESET_STATE): return 1 = ESP32C3_POWERON_RESET (matches QEMU).
if (wordOff === 0x70) {
const word = 1 << 30;
return (word >>> ((off & 3) * 8)) & 0xff;
}
if (wordOff === 0x38) {
return off === wordOff ? 1 : 0; // byte 0 = 1, rest = 0
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xff;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xff << shift)) | ((val & 0xff) << shift));
},
);
}
// ── HEX loading ────────────────────────────────────────────────────────────
/**
* Load an Intel HEX file. The hex addresses must be relative to IROM_BASE
* (0x42000000), or zero-based (the parser will treat them as flash offsets).
*/
loadHex(hexContent: string): void {
this.flash.fill(0);
const bytes = hexToUint8Array(hexContent);
// hexToUint8Array returns bytes indexed from address 0.
// If the hex records used IROM_BASE-relative addressing, the byte array
// will start at offset IROM_BASE within a huge buffer — we can't use that.
// Support both:
// a) Small array (< IROM_SIZE) → direct flash offset mapping
// b) Large array → slice from IROM_BASE offset if present
if (bytes.length <= IROM_SIZE) {
const maxCopy = Math.min(bytes.length, IROM_SIZE);
this.flash.set(bytes.subarray(0, maxCopy), 0);
} else {
// Try to extract data at IROM_BASE offset
const iromOffset = IROM_BASE;
if (bytes.length > iromOffset) {
const maxCopy = Math.min(bytes.length - iromOffset, IROM_SIZE);
this.flash.set(bytes.subarray(iromOffset, iromOffset + maxCopy), 0);
}
}
this.dram.fill(0);
this.iram.fill(0);
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
/**
* Load a raw binary image into flash at offset 0 (maps to IROM_BASE 0x42000000).
* Use this with binaries produced by:
* riscv32-esp-elf-objcopy -O binary firmware.elf firmware.bin
*/
loadBin(bin: Uint8Array): void {
this.flash.fill(0);
const maxCopy = Math.min(bin.length, IROM_SIZE);
this.flash.set(bin.subarray(0, maxCopy), 0);
this.dram.fill(0);
this.iram.fill(0);
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this._periRegs.clear();
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
/**
* Load a merged ESP32 flash image from the backend (base64-encoded).
*
* The backend produces a 4 MB merged image:
* 0x01000 — bootloader
* 0x08000 — partition table
* 0x10000 — application (ESP32 image format with segment headers)
*
* Each image segment is loaded at its virtual load address:
* IROM (0x42xxxxxx) → flash buffer (executed code)
* DROM (0x3Cxxxxxx) → flash buffer (read-only data alias)
* DRAM (0x3FCxxxxx) → dram buffer (initialised .data)
* IRAM (0x4037xxxx) → iram buffer (ISR / time-critical code)
*
* The CPU resets to the entry point declared in the image header.
*/
loadFlashImage(base64: string): void {
// Base64 decode
const binStr = atob(base64);
const data = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) data[i] = binStr.charCodeAt(i);
// Parse ESP32 image format
const parsed = parseMergedFlashImage(data);
// Clear all memory regions
this.flash.fill(0);
this.dram.fill(0);
this.iram.fill(0);
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this._periRegs.clear();
// Load each segment at its virtual address
for (const { loadAddr, data: seg } of parsed.segments) {
const uAddr = loadAddr >>> 0;
if (uAddr >= IROM_BASE && uAddr + seg.length <= IROM_BASE + IROM_SIZE) {
this.flash.set(seg, uAddr - IROM_BASE);
} else if (uAddr >= DROM_BASE && uAddr + seg.length <= DROM_BASE + IROM_SIZE) {
// DROM is a virtual alias of flash — store at same flash buffer
this.flash.set(seg, uAddr - DROM_BASE);
} else if (uAddr >= DRAM_BASE && uAddr + seg.length <= DRAM_BASE + DRAM_SIZE) {
this.dram.set(seg, uAddr - DRAM_BASE);
} else if (uAddr >= IRAM_BASE && uAddr + seg.length <= IRAM_BASE + IRAM_SIZE) {
this.iram.set(seg, uAddr - IRAM_BASE);
} else {
console.warn(
`[Esp32C3Simulator] Segment 0x${uAddr.toString(16)}` +
` (${seg.length} B) outside known regions — skipped`,
);
}
}
// Boot CPU at image entry point
this.core.reset(parsed.entryPoint);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
console.log(
`[Esp32C3Simulator] Loaded ${parsed.segments.length} segments,` +
` entry=0x${parsed.entryPoint.toString(16)}`,
);
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
start(): void {
if (this.running) return;
this._ticksPerUs = 160;
console.log(`[ESP32-C3] Simulation started, entry=0x${this.core.pc.toString(16)}`);
this.running = true;
this._loop();
}
stop(): void {
this.running = false;
cancelAnimationFrame(this.animFrameId);
}
reset(): void {
this.stop();
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this._stIntEna = 0;
this._stIntRaw = 0;
this._periRegs.clear();
this._ticksPerUs = 160;
this.dram.fill(0);
this.iram.fill(0);
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
serialWrite(text: string): void {
for (let i = 0; i < text.length; i++) {
this.rxFifo.push(text.charCodeAt(i));
}
}
setPinState(pin: number, state: boolean): void {
if (state) this.gpioIn |= 1 << pin;
else this.gpioIn &= ~(1 << pin);
}
isRunning(): boolean {
return this.running;
}
// ── Execution loop ─────────────────────────────────────────────────────────
private _loop(): void {
if (!this.running) return;
// Execute in 1 ms chunks so FreeRTOS tick interrupts fire at ~1 kHz.
let rem = CYCLES_PER_FRAME;
while (rem > 0) {
const n = rem < CYCLES_PER_TICK ? rem : CYCLES_PER_TICK;
for (let i = 0; i < n; i++) {
this.core.step();
}
rem -= n;
// Raise SYSTIMER TARGET0 alarm → routed through interrupt matrix.
this._stIntRaw |= 1;
if (this._stIntEna & 1) {
this._raiseIntSource(ETS_SYSTIMER_TARGET0_SRC);
}
}
this.animFrameId = requestAnimationFrame(() => this._loop());
}
}