/** * ESP32-C3 Browser Emulation Tests * * Tests the RV32IMC (Integer + Multiply + Compressed) emulator used * for browser-side ESP32-C3 simulation without a QEMU backend. * * Test groups: * 1. RV32M — multiply/divide instructions (via RiscVCore directly) * 2. RV32C — 16-bit compressed instructions (via RiscVCore directly) * 3. Esp32C3Simulator — UART0 serial output * 4. Esp32C3Simulator — GPIO pin toggling * 5. Lifecycle — start/stop/reset */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { RiscVCore } from '../simulation/RiscVCore'; import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; import type { PinManager } from '../simulation/PinManager'; // ── Node environment stubs ─────────────────────────────────────────────────── let rafDepth = 0; vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { // Allow a bounded number of recursive RAF calls to test the loop if (rafDepth < 2) { rafDepth++; cb(0); rafDepth--; } return 1; }); vi.stubGlobal('cancelAnimationFrame', () => {}); // ── Helpers ────────────────────────────────────────────────────────────────── function writeWord(mem: Uint8Array, offset: number, val: number): void { mem[offset] = val & 0xff; mem[offset + 1] = (val >> 8) & 0xff; mem[offset + 2] = (val >> 16) & 0xff; mem[offset + 3] = (val >> 24) & 0xff; } function writeHalf(mem: Uint8Array, offset: number, val: number): void { mem[offset] = val & 0xff; mem[offset + 1] = (val >> 8) & 0xff; } /** Run exactly n steps on a core */ function runSteps(core: RiscVCore, n: number): void { for (let i = 0; i < n; i++) core.step(); } /** Create a minimal mock PinManager */ function mockPinManager(): PinManager { return { setPinState: vi.fn(), getPinState: vi.fn(() => false), registerPin: vi.fn(), unregisterPin: vi.fn(), } as unknown as PinManager; } /** Access the simulator's internal RiscVCore for direct testing */ function getCore(sim: Esp32C3Simulator): RiscVCore { return (sim as unknown as { core: RiscVCore }).core; } /** Access the simulator's internal flash buffer for direct programming */ function getFlash(sim: Esp32C3Simulator): Uint8Array { return (sim as unknown as { flash: Uint8Array }).flash; } // ── Test Group 1: RV32M (multiply/divide) ──────────────────────────────────── describe('RV32M — multiply/divide extension', () => { let mem: Uint8Array; let core: RiscVCore; beforeEach(() => { mem = new Uint8Array(64); core = new RiscVCore(mem, 0); core.reset(0); }); it('MUL: 6 × 7 = 42', () => { writeWord(mem, 0, 0x00600093); // ADDI x1, x0, 6 writeWord(mem, 4, 0x00700113); // ADDI x2, x0, 7 writeWord(mem, 8, 0x022081b3); // MUL x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(42); }); it('MUL: negative × positive = negative', () => { writeWord(mem, 0, 0xfff00093); // ADDI x1, x0, -1 writeWord(mem, 4, 0x00300113); // ADDI x2, x0, 3 writeWord(mem, 8, 0x022081b3); // MUL x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(-3); }); it('MULH: signed upper — (-1) × (-1) upper 32 bits = 0', () => { // (-1) * (-1) = 1; upper 32 bits of 64-bit result = 0 writeWord(mem, 0, 0xfff00093); // ADDI x1, x0, -1 writeWord(mem, 4, 0xfff00113); // ADDI x2, x0, -1 writeWord(mem, 8, 0x022091b3); // MULH x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(0); }); it('MULHU: unsigned upper — 0xFFFFFFFF × 0xFFFFFFFF upper 32 bits', () => { // 0xFFFFFFFF * 0xFFFFFFFF = 0xFFFFFFFE_00000001; upper = 0xFFFFFFFE writeWord(mem, 0, 0xfff00093); // ADDI x1, x0, -1 (= 0xFFFFFFFF unsigned) writeWord(mem, 4, 0xfff00113); // ADDI x2, x0, -1 writeWord(mem, 8, 0x022081b3 | (3 << 12)); // MULHU x3, x1, x2 (funct3=3) runSteps(core, 3); expect(core.regs[3] >>> 0).toBe(0xfffffffe); }); it('DIV: 42 / 7 = 6', () => { writeWord(mem, 0, 0x02a00093); // ADDI x1, x0, 42 writeWord(mem, 4, 0x00700113); // ADDI x2, x0, 7 // DIV x3, x1, x2: opcode=0x33, rd=3, funct3=4, rs1=1, rs2=2, funct7=1 // = (1<<25)|(2<<20)|(1<<15)|(4<<12)|(3<<7)|0x33 = 0x0220C1B3 writeWord(mem, 8, 0x0220c1b3); // DIV x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(6); }); it('DIV: signed — -7 / 2 = -3 (truncate toward zero)', () => { writeWord(mem, 0, 0xff900093); // ADDI x1, x0, -7 writeWord(mem, 4, 0x00200113); // ADDI x2, x0, 2 writeWord(mem, 8, 0x0220c1b3); // DIV x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(-3); }); it('DIV: divide by zero returns -1 (0xFFFFFFFF)', () => { writeWord(mem, 0, 0x00500093); // ADDI x1, x0, 5 writeWord(mem, 4, 0x00000113); // ADDI x2, x0, 0 writeWord(mem, 8, 0x0220c1b3); // DIV x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(-1); }); it('REM: 10 % 3 = 1', () => { writeWord(mem, 0, 0x00a00093); // ADDI x1, x0, 10 writeWord(mem, 4, 0x00300113); // ADDI x2, x0, 3 // REM x3, x1, x2: funct3=6 → (1<<25)|(2<<20)|(1<<15)|(6<<12)|(3<<7)|0x33 = 0x0220E1B3 writeWord(mem, 8, 0x0220e1b3); // REM x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(1); }); it('REM: divide by zero returns dividend', () => { writeWord(mem, 0, 0x00700093); // ADDI x1, x0, 7 writeWord(mem, 4, 0x00000113); // ADDI x2, x0, 0 writeWord(mem, 8, 0x0220e1b3); // REM x3, x1, x2 runSteps(core, 3); expect(core.regs[3]).toBe(7); }); }); // ── Test Group 2: RV32C (compressed instructions) ─────────────────────────── describe('RV32C — 16-bit compressed instruction extension', () => { let mem: Uint8Array; let core: RiscVCore; beforeEach(() => { mem = new Uint8Array(64); core = new RiscVCore(mem, 0); core.reset(0); }); it('C.LI x1, 5: loads immediate 5 into x1 and advances PC by 2', () => { // C.LI: funct3=010, imm[5]=0, rd=x1(00001), imm[4:0]=00101, op=01 // Encoding: bit15..0 = 0_100_0_00001_00101_01 = 0x4095 writeHalf(mem, 0, 0x4095); core.step(); expect(core.regs[1]).toBe(5); expect(core.pc).toBe(2); }); it('C.LI x2, -1: sign-extends negative immediate', () => { // C.LI: funct3=010, imm[5]=1, rd=x2(00010), imm[4:0]=11111, op=01 // Encoding: bit15..0 = 0_100_1_00010_11111_01 = 0x5105 // Checking: bit12=1, bits[11:7]=00010=2, bits[6:2]=11111=31, bits[1:0]=01 // Value: 0_1_0_0 | 1_0_0_0 | 1_0_1_1 | 1_1_1_0_1 ... let me compute: // op=01: bit1=0,bit0=1; imm[4:0]=11111: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1 // rd=00010: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0 // imm[5]=1: bit12=1; funct3=010: bit15=0,bit14=1,bit13=0 // = 0100 1000 1111 1101 wait... // bit15=0,14=1,13=0,12=1,11=0,10=0,9=0,8=1,7=0,6=1,5=1,4=1,3=1,2=1,1=0,0=1 // = 0101 0001 0111 1101 = 0x517D? Let me just compute the halfword value: // Bits (from bit15 to bit0): // 0,1,0,1 | 0,0,0,1 | 0,1,1,1 | 1,1,0,1 // wait: funct3=010 → bits[15:13]=010: bit15=0,bit14=1,bit13=0 // bit12=1 (imm[5]=1) // rd=2=00010: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0 // imm[4:0]=11111: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1 // op=01: bit1=0,bit0=1 // = 0100_1000_1111_1101 = 0x48FD? Wait let me re-group: // bits 15..12: 0,1,0,1 = 0x5... no: bit15=0,bit14=1,bit13=0,bit12=1 → 0101 = 5? That's nibble 0101=5 // bits 11..8: 0,0,0,1 → 0001 = 1 // bits 7..4: 0,1,1,1 → 0111 = 7 // bits 3..0: 1,1,0,1 → 1101 = D // So 0x517D. Let's verify the C.LI decode: // half=0x517D: op=0x1, funct3=(0x517D>>13)&7=(0x28)&7=... 0x517D=20861, 20861>>13=2, 2&7=2 ✓ (funct3=2=C.LI) // bit12=(0x517D>>12)&1=5&1=1 ✓ // rd=(0x517D>>7)&31=(0xA2)&31... 0x517D>>7=163, 163&31=3? Hmm that gives rd=3 not rd=2... // 0x517D in binary: 0101 0001 0111 1101 // bits[11:7]: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0 = 00010 = 2 ✓ // But (0x517D>>7) = 0101 0001 0 = 162, 162&31=162-160=2 ✓ (I miscalculated before) // bits[6:2]: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1 = 11111 = 31 ✓ // imm6 = sext((1<<5)|(31), 6) = sext(63, 6) = sext(0b111111, 6) = -1 ✓ writeHalf(mem, 0, 0x517d); core.step(); expect(core.regs[2]).toBe(-1); expect(core.pc).toBe(2); }); it('C.ADDI x1, 3: adds immediate to register', () => { // Preset x1=10 writeWord(mem, 0, 0x00a00093); // ADDI x1, x0, 10 (32-bit) // C.ADDI x1, 3: funct3=000, imm[5]=0, rd=x1, imm[4:0]=00011, op=01 // bit12=0, bits[11:7]=00001, bits[6:2]=00011, bits[1:0]=01 // = 0000 0000 1000 1101 = 0x008D writeHalf(mem, 4, 0x008d); runSteps(core, 2); expect(core.regs[1]).toBe(13); expect(core.pc).toBe(6); }); it('C.MV x5, x1: copies register (ADD x5, x0, x1)', () => { // Preset x1=42 writeWord(mem, 0, 0x02a00093); // ADDI x1, x0, 42 // C.MV x5, x1: funct3=100, bit12=0, rd=x5(00101), rs2=x1(00001), op=10 // bit15=1,14=0,13=0,12=0,bits[11:7]=00101=5,bits[6:2]=00001=1,bits[1:0]=10 // = 1000 0010 1000 0110 = 0x8286 writeHalf(mem, 4, 0x8286); runSteps(core, 2); expect(core.regs[5]).toBe(42); }); it('C.ADD x1, x2: adds two registers', () => { writeWord(mem, 0, 0x00300093); // ADDI x1, x0, 3 writeWord(mem, 4, 0x00400113); // ADDI x2, x0, 4 // C.ADD x1, x2: funct3=100, bit12=1, rd=x1(00001), rs2=x2(00010), op=10 // bit15=1,14=0,13=0,12=1,bits[11:7]=00001,bits[6:2]=00010,bits[1:0]=10 // = 1001 0000 1000 1010 = 0x908A writeHalf(mem, 8, 0x908a); runSteps(core, 3); expect(core.regs[1]).toBe(7); }); it('C.J +4: jumps forward 4 bytes from compressed instruction', () => { // C.J with offset=4, starting at PC=0 // CJ format: funct3=101, imm[3:1]=010 → bits[5:3]=010 → bit4=1, bits[1:0]=01 // = 1010_0000_0001_0001 = 0xA011 writeHalf(mem, 0, 0xa011); core.step(); expect(core.pc).toBe(4); // 0 + 4 }); it('C.BEQZ x8, offset: branch taken when register is zero', () => { // x8 is 0 (default), so branch should be taken // C.BEQZ x8, +4: rs1'=x8(=0 encoded as 0b000), offset=4 // CB format: funct3=110, imm[8]=0, rs1'=000, imm[7:6]=00, imm[2:1]=10, imm[5]=0, op=01 // offset=4: imm[2:1]=10 → bits[4:3]=10=2, other imm bits=0 // bit15=0,14=1,13=1,12=0,bit11=0,bit10=0,bits[9:7]=000,bit6=0,bit5=0,bits[4:3]=10,bit2=0,bit1=0,bit0=1 // = 0110 0000 0001 0001 ... let me compute more carefully // CB: bits[15:13]=110, bit[12]=imm[8]=0, bits[11:10]=imm[4:3]=00, bits[9:7]=rs1'=000 // bits[6:5]=imm[7:6]=00, bits[4:3]=imm[2:1]=10, bit[2]=imm[5]=0, bits[1:0]=01 // For offset=4: imm[2:1]=10 → bits[4:3]=10 → bit4=1, bit3=0 // = 1100_0000_0001_0001 = 0xC011 writeHalf(mem, 0, 0xc011); core.step(); expect(core.pc).toBe(4); }); it('C.BEQZ x8, offset: branch NOT taken when register is non-zero', () => { core.regs[8] = 5; writeHalf(mem, 0, 0xc011); // C.BEQZ x8, +4 core.step(); expect(core.pc).toBe(2); // falls through }); it('C.SWSP + C.LWSP: stack round-trip', () => { // Set sp (x2) to offset 32 within our buffer (so stack writes stay in bounds) core.regs[2] = 32; core.regs[1] = 0xdead; // C.SWSP rs2=x1, offset=0: // CSS: funct3=110, uimm[5:2]=bits[12:9]=0000, uimm[7:6]=bits[8:7]=00, rs2=bits[6:2]=00001, op=10 // = 1101 0000 0000 0110 = 0xD006? let me compute: // bit15=1,14=1,13=0,12=0,bits[11:10]=uimm[5:4]=00,bits[9:7]=uimm[3:1]=000,bits[6:2]=rs2=00001,bits[1:0]=10 // Wait the spec says: bits[12:9]=uimm[5:2], bits[8:7]=uimm[7:6] // For offset=0: all uimm bits=0 → bits[12:9]=0000, bits[8:7]=00 // = 1101 0000 0000 0110 = 0xD006? // bit15=1,bit14=1,bit13=0,bit12=0 → 1100 // bits[11:10]=00, bits[9:8]=00 → 0000 // bits[7]=0 → 0 // bits[6:2]=00001 → bit6=0,bit5=0,bit4=0,bit3=0,bit2=1 // bits[1:0]=10 // = 1100 0000 0000 0110 = 0xC006 writeHalf(mem, 0, 0xc006); // C.SWSP x1, 0(sp) // C.LWSP rd=x3, offset=0: // CI: funct3=010, bit12=uimm[5]=0, rd=x3=00011, bits[6:4]=uimm[4:2]=000, bits[3:2]=uimm[7:6]=00, op=10 // = 0100 0001 1000 0010 = 0x4182 writeHalf(mem, 2, 0x4182); // C.LWSP x3, 0(sp) runSteps(core, 2); expect(core.regs[3]).toBe(0xdead); }); }); // ── Test Group 3: Esp32C3Simulator — UART ─────────────────────────────────── describe('Esp32C3Simulator — UART0 serial output', () => { let sim: Esp32C3Simulator; beforeEach(() => { sim = new Esp32C3Simulator(mockPinManager()); }); afterEach(() => { sim.stop(); }); it('writing to UART0 FIFO (0x60000000) triggers onSerialData', () => { const received: string[] = []; sim.onSerialData = (ch) => received.push(ch); const flash = getFlash(sim); const core = getCore(sim); // Program at IROM offset 0 (= address 0x42000000): // LUI a1, 0x60000 → a1 = 0x60000000 (UART0_BASE) // ADDI a0, x0, 72 → a0 = 72 = 'H' // SB a0, 0(a1) → write byte to UART0 FIFO writeWord(flash, 0, 0x600005b7); // LUI a1, 0x60000 writeWord(flash, 4, 0x04800513); // ADDI a0, x0, 72 writeWord(flash, 8, 0x00a58023); // SB a0, 0(a1) core.reset(0x42000000); runSteps(core, 3); expect(received).toEqual(['H']); }); it('writing multiple bytes emits each character', () => { const received: string[] = []; sim.onSerialData = (ch) => received.push(ch); const flash = getFlash(sim); const core = getCore(sim); // LUI a1, 0x60000 → a1 = UART0_BASE // ADDI a0, x0, 65 ('A') // SB a0, 0(a1) // ADDI a0, x0, 66 ('B') // SB a0, 0(a1) writeWord(flash, 0, 0x600005b7); // LUI a1, 0x60000 writeWord(flash, 4, 0x04100513); // ADDI a0, x0, 65 ('A') writeWord(flash, 8, 0x00a58023); // SB a0, 0(a1) writeWord(flash, 12, 0x04200513); // ADDI a0, x0, 66 ('B') writeWord(flash, 16, 0x00a58023); // SB a0, 0(a1) core.reset(0x42000000); runSteps(core, 5); expect(received).toEqual(['A', 'B']); }); it('serialWrite injects bytes into RX FIFO, firmware can read them', () => { const flash = getFlash(sim); const core = getCore(sim); sim.serialWrite('X'); // Program: LB a0, 0(a1) — reads from UART0_FIFO // LUI a1, 0x60000 → a1 = 0x60000000 // LBU a0, 0(a1) → a0 = UART0_FIFO read writeWord(flash, 0, 0x600005b7); // LUI a1, 0x60000 writeWord(flash, 4, 0x00058503); // LBU a0, 0(a1) core.reset(0x42000000); runSteps(core, 2); expect(core.regs[10]).toBe('X'.charCodeAt(0)); // a0 = 88 = 'X' }); }); // ── Test Group 4: Esp32C3Simulator — GPIO ──────────────────────────────────── describe('Esp32C3Simulator — GPIO pin toggling', () => { let sim: Esp32C3Simulator; beforeEach(() => { sim = new Esp32C3Simulator(mockPinManager()); }); afterEach(() => { sim.stop(); }); it('SW to GPIO_OUT_W1TS (offset +8) sets GPIO0 high', () => { const pinChanges: Array<{ pin: number; state: boolean }> = []; sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state }); const flash = getFlash(sim); const core = getCore(sim); // LUI t1, 0x60004 → t1 = 0x60004000 (GPIO_BASE) // ADDI t0, x0, 1 → t0 = 1 (bit 0 = GPIO0) // SW t0, 8(t1) → write to GPIO_OUT_W1TS writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004 writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1 writeWord(flash, 8, 0x00532423); // SW t0, 8(t1) [offset 8 = W1TS] core.reset(0x42000000); runSteps(core, 3); expect(pinChanges).toContainEqual({ pin: 0, state: true }); }); it('SW to GPIO_OUT_W1TC (offset +12) clears GPIO0', () => { const pinChanges: Array<{ pin: number; state: boolean }> = []; sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state }); const flash = getFlash(sim); const core = getCore(sim); // First set GPIO0 high via W1TS, then clear via W1TC writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004 writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1 writeWord(flash, 8, 0x00532423); // SW t0, 8(t1) — set bit 0 (W1TS) writeWord(flash, 12, 0x00532623); // SW t0, 12(t1) — clear bit 0 (W1TC) core.reset(0x42000000); runSteps(core, 4); expect(pinChanges).toContainEqual({ pin: 0, state: true }); expect(pinChanges).toContainEqual({ pin: 0, state: false }); }); it('SW to GPIO_OUT sets multiple pins via direct write', () => { const setPins: number[] = []; sim.onPinChangeWithTime = (pin, state) => { if (state) setPins.push(pin); }; const flash = getFlash(sim); const core = getCore(sim); // Write 0b101 (bits 0 and 2) to GPIO_OUT (offset +4) writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004 writeWord(flash, 4, 0x00500293); // ADDI t0, x0, 5 (0b101) writeWord(flash, 8, 0x00532223); // SW t0, 4(t1) — GPIO_OUT core.reset(0x42000000); runSteps(core, 3); expect(setPins).toContain(0); expect(setPins).toContain(2); expect(setPins).not.toContain(1); }); it('pinManager.setPinState is called on GPIO change', () => { const pm = mockPinManager(); const s = new Esp32C3Simulator(pm); const flash = getFlash(s); const core = getCore(s); writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004 writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1 writeWord(flash, 8, 0x00532423); // SW t0, 8(t1) core.reset(0x42000000); runSteps(core, 3); expect(pm.setPinState).toHaveBeenCalledWith(0, true, 'mcu'); s.stop(); }); }); // ── Test Group 5: Lifecycle ────────────────────────────────────────────────── describe('Esp32C3Simulator — lifecycle', () => { it('starts not running', () => { const sim = new Esp32C3Simulator(mockPinManager()); expect(sim.isRunning()).toBe(false); sim.stop(); }); it('start() sets running, stop() clears it', () => { const sim = new Esp32C3Simulator(mockPinManager()); sim.start(); expect(sim.isRunning()).toBe(true); sim.stop(); expect(sim.isRunning()).toBe(false); }); it('reset() stops simulator and clears register state', () => { const sim = new Esp32C3Simulator(mockPinManager()); const core = getCore(sim); core.regs[1] = 999; sim.start(); sim.reset(); expect(sim.isRunning()).toBe(false); expect(core.regs[1]).toBe(0); expect(core.pc).toBe(0x42000000); }); it('reset() clears GPIO output state', () => { const pinChanges: Array<{ pin: number; state: boolean }> = []; const sim = new Esp32C3Simulator(mockPinManager()); sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state }); const flash = getFlash(sim); const core = getCore(sim); writeWord(flash, 0, 0x60004337); writeWord(flash, 4, 0x00100293); writeWord(flash, 8, 0x00532423); core.reset(0x42000000); runSteps(core, 3); sim.reset(); const gpioOut = (sim as unknown as { gpioOut: number }).gpioOut; expect(gpioOut).toBe(0); }); it('double start() is a no-op (does not create duplicate loops)', () => { let rafCalls = 0; vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { rafCalls++; // Don't recurse return rafCalls; }); const sim = new Esp32C3Simulator(mockPinManager()); sim.start(); sim.start(); // second call should be ignored expect(rafCalls).toBe(1); sim.stop(); }); });