diff --git a/frontend/src/__tests__/esp32-integration.test.ts b/frontend/src/__tests__/esp32-integration.test.ts index 51a3b08..e5c754f 100644 --- a/frontend/src/__tests__/esp32-integration.test.ts +++ b/frontend/src/__tests__/esp32-integration.test.ts @@ -293,14 +293,12 @@ describe('Esp32Bridge — WebSocket protocol', () => { s3Bridge.disconnect(); }); - it('ESP32-C3 bridge sends esp32-c3 in start_esp32', () => { + it('ESP32-C3 no longer uses Esp32Bridge — uses browser-side Esp32C3Simulator', () => { + // ESP32-C3 was moved from QEMU to the browser RV32IMC emulator. + // Creating an Esp32Bridge manually still works (the class is not removed), + // but addBoard('esp32-c3') will now create an Esp32C3Simulator instead. const c3Bridge = new Esp32Bridge('test-esp32-c3', 'esp32-c3'); - c3Bridge.connect(); - const c3Ws = (c3Bridge as any).socket as MockWebSocket; - c3Ws.open(); - const msg = JSON.parse(c3Ws.sent[0]); - expect(msg.data.board).toBe('esp32-c3'); - c3Bridge.disconnect(); + expect(c3Bridge.boardKind).toBe('esp32-c3'); // bridge still instantiatable }); }); @@ -330,10 +328,12 @@ describe('useSimulatorStore — ESP32 boards', () => { expect(getEsp32Bridge(id)?.boardKind).toBe('esp32-s3'); }); - it('addBoard("esp32-c3") creates bridge with correct boardKind', () => { + it('addBoard("esp32-c3") creates an Esp32C3Simulator, not an Esp32Bridge', () => { const { addBoard } = useSimulatorStore.getState(); const id = addBoard('esp32-c3', 300, 100); - expect(getEsp32Bridge(id)?.boardKind).toBe('esp32-c3'); + // ESP32-C3 uses the browser-side RV32IMC emulator — no QEMU bridge + expect(getEsp32Bridge(id)).toBeUndefined(); + expect(getBoardSimulator(id)).toBeDefined(); }); it('addBoard creates a file group with sketch.ino (not script.py)', () => { diff --git a/frontend/src/__tests__/esp32c3-blink.test.ts b/frontend/src/__tests__/esp32c3-blink.test.ts new file mode 100644 index 0000000..8c25eba --- /dev/null +++ b/frontend/src/__tests__/esp32c3-blink.test.ts @@ -0,0 +1,192 @@ +/** + * ESP32-C3 blink integration test. + * + * Compiles a bare-metal C program with riscv32-esp-elf-gcc (the toolchain + * bundled with arduino-cli's ESP32 package), loads the raw binary into + * Esp32C3Simulator, and verifies that GPIO 8 toggles as expected. + * + * Why bare-metal and not a full Arduino sketch? + * The full ESP-IDF/Arduino framework requires dozens of peripherals + * (cache controller, interrupt matrix, RTC, FreeRTOS scheduler, etc.) that + * the browser-side emulator does not implement. A bare-metal program that + * writes directly to GPIO MMIO registers tests exactly what the emulator + * is built for: pure RV32IMC instruction execution with GPIO/UART MMIO. + * + * Source: frontend/src/__tests__/fixtures/esp32c3-blink/blink.c + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; +import type { PinManager } from '../simulation/PinManager'; + +// ── Mocks (Node has no requestAnimationFrame) ───────────────────────────────── +vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { cb(); return 0; }); +vi.stubGlobal('cancelAnimationFrame', () => {}); + +// ── Paths ───────────────────────────────────────────────────────────────────── +const FIXTURE_DIR = join(__dirname, 'fixtures/esp32c3-blink'); +const BIN_PATH = join(FIXTURE_DIR, 'blink.bin'); +const DIS_PATH = join(FIXTURE_DIR, 'blink.dis'); +const BUILD_SH = join(FIXTURE_DIR, 'build.sh'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function mockPinManager(): PinManager { + return { + setPinState: vi.fn(), + getPinState: vi.fn(() => false), + } as unknown as PinManager; +} + +/** Run N CPU steps on the simulator's internal core */ +function runSteps(sim: Esp32C3Simulator, n: number): void { + const core = (sim as unknown as { core: { step(): void } }).core; + for (let i = 0; i < n; i++) core.step(); +} + +// ── Build the binary once before all tests ──────────────────────────────────── +let buildError: string | null = null; +let binData: Uint8Array; + +beforeAll(() => { + // Rebuild whenever the source changes (or if binary is missing) + try { + execSync(`bash "${BUILD_SH}"`, { stdio: 'pipe' }); + } catch (err: unknown) { + buildError = String((err as NodeJS.ErrnoException).stderr ?? err); + if (!existsSync(BIN_PATH)) return; // binary missing AND build failed + // Binary exists from a previous build — use it but warn + console.warn('[esp32c3-blink] Build script failed; using existing binary.\n' + buildError); + buildError = null; + } + binData = new Uint8Array(readFileSync(BIN_PATH)); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ESP32-C3 bare-metal blink (compiled with riscv32-esp-elf-gcc)', () => { + + it('build.sh produces a non-empty blink.bin', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + expect(binData.byteLength).toBeGreaterThan(0); + expect(binData.byteLength).toBeLessThan(4096); // sanity: tiny bare-metal binary + console.log(`Binary size: ${binData.byteLength} bytes`); + }); + + it('binary starts with a valid RV32 instruction at 0x42000000', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + // First 4 bytes must decode as a valid 32-bit or 16-bit RISC-V instruction + const b0 = binData[0], b1 = binData[1]; + const half = b0 | (b1 << 8); + const is32bit = (half & 0x3) === 0x3; + const is16bit = (half & 0x3) !== 0x3; + expect(is32bit || is16bit).toBe(true); + + // First instruction in the disassembly should be _start at 0x42000000 + if (existsSync(DIS_PATH)) { + const dis = readFileSync(DIS_PATH, 'utf8'); + expect(dis).toContain('42000000 <_start>'); + console.log('\nDisassembly (first 20 lines):\n' + dis.split('\n').slice(0, 20).join('\n')); + } + }); + + it('loadBin() loads the binary and resets PC to 0x42000000', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const sim = new Esp32C3Simulator(mockPinManager()); + sim.loadBin(binData); + const core = (sim as unknown as { core: { pc: number } }).core; + expect(core.pc).toBe(0x42000000); + }); + + it('GPIO 8 goes HIGH after the first SW to GPIO_W1TS', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const pm = mockPinManager(); + const sim = new Esp32C3Simulator(pm); + const pinEvents: Array<{ pin: number; state: boolean; timeMs: number }> = []; + sim.onPinChangeWithTime = (pin, state, timeMs) => pinEvents.push({ pin, state, timeMs }); + + sim.loadBin(binData); + // The blink program writes GPIO_W1TS within the first ~10 instructions + runSteps(sim, 20); + + const gpio8Events = pinEvents.filter(e => e.pin === 8); + expect(gpio8Events.length).toBeGreaterThan(0); + expect(gpio8Events[0].state).toBe(true); // first event is LED ON + expect(gpio8Events[0].timeMs).toBeGreaterThan(0); // has a meaningful timestamp + + console.log(`GPIO 8 went HIGH at ${gpio8Events[0].timeMs.toFixed(4)} ms (simulated)`); + }); + + it('GPIO 8 toggles ON and OFF within a full blink cycle', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const pm = mockPinManager(); + const sim = new Esp32C3Simulator(pm); + const pinEvents: Array<{ pin: number; state: boolean }> = []; + sim.onPinChangeWithTime = (pin, state) => pinEvents.push({ pin, state }); + + sim.loadBin(binData); + // delay(50) = ~200 instructions; two delays + GPIO writes ≈ 410 instructions/cycle + // Run 2000 steps → ~4 complete blink cycles + runSteps(sim, 2000); + + const gpio8 = pinEvents.filter(e => e.pin === 8); + const highEvents = gpio8.filter(e => e.state === true); + const lowEvents = gpio8.filter(e => e.state === false); + + expect(highEvents.length).toBeGreaterThanOrEqual(2); // at least 2 ON events + expect(lowEvents.length).toBeGreaterThanOrEqual(2); // at least 2 OFF events + + // Events should strictly alternate: HIGH, LOW, HIGH, LOW ... + for (let i = 0; i < gpio8.length - 1; i++) { + expect(gpio8[i].state).not.toBe(gpio8[i + 1].state); + } + + console.log(`GPIO 8 toggled ${gpio8.length} times in 2000 steps (${highEvents.length} ON, ${lowEvents.length} OFF)`); + }); + + it('PinManager.setPinState is called with correct pin and state', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const pm = mockPinManager(); + const sim = new Esp32C3Simulator(pm); + sim.loadBin(binData); + runSteps(sim, 2000); + + // PinManager must have received GPIO 8 state changes + expect(pm.setPinState).toHaveBeenCalledWith(8, true); + expect(pm.setPinState).toHaveBeenCalledWith(8, false); + }); + + it('timestamps increase monotonically across blink events', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const sim = new Esp32C3Simulator(mockPinManager()); + const times: number[] = []; + sim.onPinChangeWithTime = (pin, _state, timeMs) => { + if (pin === 8) times.push(timeMs); + }; + + sim.loadBin(binData); + runSteps(sim, 2000); + + expect(times.length).toBeGreaterThan(2); + for (let i = 1; i < times.length; i++) { + expect(times[i]).toBeGreaterThan(times[i - 1]); + } + const totalSimMs = times[times.length - 1]; + console.log(`Simulated ${totalSimMs.toFixed(4)} ms over 2000 steps`); + }); + + it('reset() after run clears GPIO state', () => { + if (buildError) throw new Error('Build failed:\n' + buildError); + const sim = new Esp32C3Simulator(mockPinManager()); + sim.loadBin(binData); + runSteps(sim, 500); + + sim.reset(); + expect(sim.isRunning()).toBe(false); + + const core = (sim as unknown as { core: { pc: number }; gpioOut: number }).core; + expect(core.pc).toBe(0x42000000); + }); +}); diff --git a/frontend/src/__tests__/esp32c3-simulation.test.ts b/frontend/src/__tests__/esp32c3-simulation.test.ts new file mode 100644 index 0000000..a4d2bcd --- /dev/null +++ b/frontend/src/__tests__/esp32c3-simulation.test.ts @@ -0,0 +1,543 @@ +/** + * 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); + 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(); + }); +}); diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/blink.bin b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.bin new file mode 100644 index 0000000..777087e Binary files /dev/null and b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.bin differ diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/blink.c b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.c new file mode 100644 index 0000000..adaedc2 --- /dev/null +++ b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.c @@ -0,0 +1,35 @@ +/** + * ESP32-C3 bare-metal LED blink — no ESP-IDF, no FreeRTOS. + * + * Targets RV32IMC directly. Compiled with the riscv32-esp-elf-gcc toolchain + * bundled inside the arduino-cli ESP32 package. + * + * Memory map (matches Esp32C3Simulator): + * Flash (IROM) @ 0x42000000 — code lands here + * DRAM @ 0x3FC80000 — stack/data + * GPIO_W1TS @ 0x60004008 — set pin high + * GPIO_W1TC @ 0x6000400C — set pin low + * + * LED: GPIO 8 (ESP32-C3-DevKit onboard LED) + */ + +#define GPIO_W1TS (*(volatile unsigned int *)0x60004008u) +#define GPIO_W1TC (*(volatile unsigned int *)0x6000400Cu) + +#define LED_PIN 8u +#define LED_BIT (1u << LED_PIN) + +/* Short delay — small enough for the test to run fast */ +static void delay(int n) { + for (volatile int i = 0; i < n; i++); +} + +/* Entry point — the linker script places _start at 0x42000000 */ +void _start(void) { + while (1) { + GPIO_W1TS = LED_BIT; /* LED ON — sets bit 8 of gpioOut */ + delay(50); + GPIO_W1TC = LED_BIT; /* LED OFF — clears bit 8 of gpioOut */ + delay(50); + } +} diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/blink.dis b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.dis new file mode 100644 index 0000000..bfb3ca9 --- /dev/null +++ b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.dis @@ -0,0 +1,29 @@ + +E:/Hardware/wokwi_clon/frontend/src/__tests__/fixtures/esp32c3-blink/blink.elf: file format elf32-littleriscv + + +Disassembly of section .text: + +42000000 <_start>: +42000000: 60004737 lui x14,0x60004 +42000004: 1141 addi x2,x2,-16 +42000006: 00870613 addi x12,x14,8 # 60004008 <_start+0x1e004008> +4200000a: 10000593 li x11,256 +4200000e: 10000513 li x10,256 +42000012: 03100693 li x13,49 +42000016: c208 sw x10,0(x12) +42000018: c602 sw x0,12(x2) +4200001a: 47b2 lw x15,12(x2) +4200001c: 00f6db63 bge x13,x15,42000032 <_start+0x32> +42000020: c74c sw x11,12(x14) +42000022: c402 sw x0,8(x2) +42000024: 47a2 lw x15,8(x2) +42000026: fef6c8e3 blt x13,x15,42000016 <_start+0x16> +4200002a: 47a2 lw x15,8(x2) +4200002c: 0785 addi x15,x15,1 +4200002e: c43e sw x15,8(x2) +42000030: bfd5 j 42000024 <_start+0x24> +42000032: 47b2 lw x15,12(x2) +42000034: 0785 addi x15,x15,1 +42000036: c63e sw x15,12(x2) +42000038: b7cd j 4200001a <_start+0x1a> diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/blink.elf b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.elf new file mode 100644 index 0000000..2494df6 Binary files /dev/null and b/frontend/src/__tests__/fixtures/esp32c3-blink/blink.elf differ diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/build.sh b/frontend/src/__tests__/fixtures/esp32c3-blink/build.sh new file mode 100644 index 0000000..da3eb7d --- /dev/null +++ b/frontend/src/__tests__/fixtures/esp32c3-blink/build.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Compile the bare-metal ESP32-C3 blink program using the riscv32-esp-elf-gcc +# toolchain that ships with the arduino-cli ESP32 package. +# +# Output: +# blink.elf — ELF with debug info +# blink.bin — raw binary, loaded directly into Esp32C3Simulator flash buffer +# blink.dis — disassembly for debugging + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Locate toolchain ────────────────────────────────────────────────────────── +ARDUINO15="${LOCALAPPDATA:-$HOME/.arduino15}/Arduino15" +TOOLCHAIN_BASE="$ARDUINO15/packages/esp32/tools/riscv32-esp-elf-gcc" + +if [ ! -d "$TOOLCHAIN_BASE" ]; then + echo "ERROR: ESP32 RISC-V toolchain not found at $TOOLCHAIN_BASE" + echo "Install with: arduino-cli core install esp32:esp32" + exit 1 +fi + +# Pick the newest version directory +TOOLCHAIN_VER=$(ls "$TOOLCHAIN_BASE" | sort -V | tail -1) +TOOLCHAIN_BIN="$TOOLCHAIN_BASE/$TOOLCHAIN_VER/bin" + +GCC="$TOOLCHAIN_BIN/riscv32-esp-elf-gcc" +OBJCOPY="$TOOLCHAIN_BIN/riscv32-esp-elf-objcopy" +OBJDUMP="$TOOLCHAIN_BIN/riscv32-esp-elf-objdump" + +if [ ! -f "$GCC.exe" ] && [ ! -f "$GCC" ]; then + echo "ERROR: riscv32-esp-elf-gcc not found in $TOOLCHAIN_BIN" + exit 1 +fi + +# On Windows inside Git Bash, .exe is appended automatically +GCC="${GCC}.exe" 2>/dev/null || GCC="$GCC" +OBJCOPY="${OBJCOPY}.exe" 2>/dev/null || OBJCOPY="$OBJCOPY" +OBJDUMP="${OBJDUMP}.exe" 2>/dev/null || OBJDUMP="$OBJDUMP" + +echo "Toolchain: $TOOLCHAIN_BIN" +echo "Compiler: $GCC" + +# ── Compile ─────────────────────────────────────────────────────────────────── +"$GCC" \ + -march=rv32imc \ + -mabi=ilp32 \ + -Os \ + -ffunction-sections \ + -fdata-sections \ + -nostdlib \ + -nostartfiles \ + -T "$SCRIPT_DIR/link.ld" \ + -Wl,--gc-sections \ + -o "$SCRIPT_DIR/blink.elf" \ + "$SCRIPT_DIR/blink.c" + +# ── Extract raw binary (flash offset 0 → loads at IROM_BASE 0x42000000) ─────── +"$OBJCOPY" -O binary "$SCRIPT_DIR/blink.elf" "$SCRIPT_DIR/blink.bin" + +# ── Disassembly for inspection ───────────────────────────────────────────────── +"$OBJDUMP" -d -M numeric "$SCRIPT_DIR/blink.elf" > "$SCRIPT_DIR/blink.dis" + +BIN_SIZE=$(wc -c < "$SCRIPT_DIR/blink.bin" | tr -d ' ') +echo "" +echo "✓ blink.bin — ${BIN_SIZE} bytes" +echo "✓ blink.dis — disassembly" +echo "" +echo "Entry point: 0x42000000 (first instruction in flash)" diff --git a/frontend/src/__tests__/fixtures/esp32c3-blink/link.ld b/frontend/src/__tests__/fixtures/esp32c3-blink/link.ld new file mode 100644 index 0000000..9308894 --- /dev/null +++ b/frontend/src/__tests__/fixtures/esp32c3-blink/link.ld @@ -0,0 +1,33 @@ +/* + * Linker script for ESP32-C3 bare-metal blink. + * + * Places .text at 0x42000000 (IROM_BASE — the flash instruction region + * that Esp32C3Simulator maps as its primary memory). + * Stack lives at the top of DRAM (0x3FC80000 + 384KB). + */ +OUTPUT_ARCH(riscv) +ENTRY(_start) + +MEMORY { + IROM (rx) : ORIGIN = 0x42000000, LENGTH = 4M + DRAM (rwx) : ORIGIN = 0x3FC80000, LENGTH = 384K +} + +SECTIONS { + .text 0x42000000 : { + KEEP(*(.text.startup)) + *(.text*) + *(.rodata*) + } > IROM + + .data : { + *(.data*) + } > DRAM AT > IROM + + .bss : { + *(.bss*) + *(COMMON) + } > DRAM + + /DISCARD/ : { *(.comment) *(.note*) *(.eh_frame*) } +} diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 361427b..9e5393a 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -131,7 +131,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi const handleRun = () => { if (activeBoardId) { const board = boards.find((b) => b.id === activeBoardId); - const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3' || board?.boardKind === 'esp32-c3'; + const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3'; if (isQemuBoard || board?.compiledProgram) { startBoard(activeBoardId); setMessage(null); @@ -213,7 +213,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi const boardsList = useSimulatorStore.getState().boards; for (const board of boardsList) { const isQemu = board.boardKind === 'raspberry-pi-3' || - board.boardKind === 'esp32' || board.boardKind === 'esp32-s3' || board.boardKind === 'esp32-c3'; + board.boardKind === 'esp32' || board.boardKind === 'esp32-s3'; if (!board.running && (isQemu || board.compiledProgram)) { startBoard(board.id); } @@ -306,7 +306,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi {/* Run */}