2026-03-16 01:39:26 +07:00
|
|
|
/**
|
|
|
|
|
* RiscVSimulator — CH32V003-compatible RV32I simulator wrapper.
|
|
|
|
|
*
|
|
|
|
|
* Wraps RiscVCore with:
|
|
|
|
|
* - requestAnimationFrame execution loop (~48 MHz @ 60 fps)
|
|
|
|
|
* - CH32V003 MMIO: UART1 (0x40013800), GPIO A/C/D (0x40010800/0x40010C00/0x40011400)
|
|
|
|
|
* - Intel HEX loader (flash @ 0x08000000, RAM @ 0x20000000)
|
|
|
|
|
* - Serial I/O and pin-change callbacks matching AVRSimulator interface
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { RiscVCore } from './RiscVCore';
|
|
|
|
|
import { PinManager } from './PinManager';
|
|
|
|
|
import { hexToUint8Array } from '../utils/hexParser';
|
|
|
|
|
|
|
|
|
|
// CH32V003 memory map
|
|
|
|
|
const FLASH_BASE = 0x08000000;
|
2026-04-22 02:45:45 +07:00
|
|
|
const RAM_BASE = 0x20000000;
|
|
|
|
|
const FLASH_SIZE = 16 * 1024; // 16 KB
|
|
|
|
|
const RAM_SIZE = 2 * 1024; // 2 KB
|
2026-03-16 01:39:26 +07:00
|
|
|
|
|
|
|
|
// Combined flat buffer: flash first, then RAM
|
|
|
|
|
const MEM_SIZE = FLASH_SIZE + RAM_SIZE;
|
|
|
|
|
|
|
|
|
|
// CH32V003 clock
|
|
|
|
|
const CPU_HZ = 48_000_000;
|
|
|
|
|
const CYCLES_PER_FRAME = Math.round(CPU_HZ / 60);
|
|
|
|
|
|
|
|
|
|
// ── CH32V003 UART1 MMIO (0x40013800) ────────────────────────────────────────
|
|
|
|
|
// STATR offset 0x00 — status register (bit 7 = TXE, bit 5 = RXNE, bit 6 = TC)
|
|
|
|
|
// DATAR offset 0x04 — data register (write = TX, read = RX)
|
2026-04-22 02:45:45 +07:00
|
|
|
const UART1_BASE = 0x40013800;
|
|
|
|
|
const UART1_SIZE = 0x400;
|
2026-03-16 01:39:26 +07:00
|
|
|
const UART1_STATR = 0x00;
|
|
|
|
|
const UART1_DATAR = 0x04;
|
|
|
|
|
|
|
|
|
|
// ── CH32V003 GPIO MMIO ───────────────────────────────────────────────────────
|
|
|
|
|
// Each GPIO bank: CRL=0x00, CRH=0x04, INDR=0x08, OUTDR=0x0C, BSHR=0x10, BCR=0x14, LCKR=0x18
|
|
|
|
|
const GPIOA_BASE = 0x40010800;
|
2026-04-22 02:45:45 +07:00
|
|
|
const GPIOC_BASE = 0x40010c00;
|
2026-03-16 01:39:26 +07:00
|
|
|
const GPIOD_BASE = 0x40011400;
|
2026-04-22 02:45:45 +07:00
|
|
|
const GPIO_SIZE = 0x400;
|
|
|
|
|
const GPIO_OUTDR = 0x0c; // Output data register
|
2026-03-16 01:39:26 +07:00
|
|
|
|
|
|
|
|
// Pin offsets: PA0-7 → simulator pins 0-7, PC0-7 → 8-15, PD0-7 → 16-23
|
|
|
|
|
const GPIO_PIN_OFFSET: Record<number, number> = {
|
|
|
|
|
[GPIOA_BASE]: 0,
|
|
|
|
|
[GPIOC_BASE]: 8,
|
|
|
|
|
[GPIOD_BASE]: 16,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class RiscVSimulator {
|
|
|
|
|
private core: RiscVCore;
|
|
|
|
|
private running = false;
|
|
|
|
|
private animFrameId = 0;
|
|
|
|
|
private rxFifo: number[] = [];
|
|
|
|
|
private gpioOutdr: Record<number, number> = {
|
|
|
|
|
[GPIOA_BASE]: 0,
|
|
|
|
|
[GPIOC_BASE]: 0,
|
|
|
|
|
[GPIOD_BASE]: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// Flat memory: flash at offset 0, RAM at offset FLASH_SIZE
|
|
|
|
|
const mem = new Uint8Array(MEM_SIZE);
|
|
|
|
|
this.core = new RiscVCore(mem, FLASH_BASE);
|
|
|
|
|
|
|
|
|
|
this._registerUart();
|
|
|
|
|
this._registerGpio(GPIOA_BASE);
|
|
|
|
|
this._registerGpio(GPIOC_BASE);
|
|
|
|
|
this._registerGpio(GPIOD_BASE);
|
|
|
|
|
|
|
|
|
|
// Map RAM: RiscVCore only covers [FLASH_BASE, FLASH_BASE + MEM_SIZE).
|
|
|
|
|
// To make RAM work we extend by adding a second MMIO region that redirects
|
|
|
|
|
// to the same flat buffer at offset FLASH_SIZE.
|
|
|
|
|
const ramOffset = FLASH_SIZE;
|
2026-04-22 02:45:45 +07:00
|
|
|
this.core.addMmio(
|
|
|
|
|
RAM_BASE,
|
|
|
|
|
RAM_SIZE,
|
2026-03-16 01:39:26 +07:00
|
|
|
(addr) => mem[ramOffset + (addr - RAM_BASE)],
|
2026-04-22 02:45:45 +07:00
|
|
|
(addr, val) => {
|
|
|
|
|
mem[ramOffset + (addr - RAM_BASE)] = val;
|
|
|
|
|
},
|
2026-03-16 01:39:26 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── MMIO registration ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private _registerUart(): void {
|
2026-04-22 02:45:45 +07:00
|
|
|
this.core.addMmio(
|
|
|
|
|
UART1_BASE,
|
|
|
|
|
UART1_SIZE,
|
2026-03-16 01:39:26 +07:00
|
|
|
(addr) => {
|
|
|
|
|
const off = addr - UART1_BASE;
|
|
|
|
|
if (off === UART1_STATR) {
|
|
|
|
|
// TXE (bit 7) always ready; RXNE (bit 5) set when RX FIFO has data
|
|
|
|
|
return 0b1000_0000 | (this.rxFifo.length > 0 ? 0b0010_0000 : 0);
|
|
|
|
|
}
|
|
|
|
|
if (off === UART1_DATAR) {
|
|
|
|
|
return this.rxFifo.length > 0 ? this.rxFifo.shift()! : 0;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
(addr, val) => {
|
|
|
|
|
const off = addr - UART1_BASE;
|
|
|
|
|
if (off === UART1_DATAR) {
|
|
|
|
|
this.onSerialData?.(String.fromCharCode(val & 0xff));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _registerGpio(base: number): void {
|
|
|
|
|
const pinOffset = GPIO_PIN_OFFSET[base];
|
2026-04-22 02:45:45 +07:00
|
|
|
this.core.addMmio(
|
|
|
|
|
base,
|
|
|
|
|
GPIO_SIZE,
|
2026-03-16 01:39:26 +07:00
|
|
|
(addr) => {
|
|
|
|
|
const off = addr - base;
|
|
|
|
|
if (off === GPIO_OUTDR) return this.gpioOutdr[base];
|
|
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
(addr, val) => {
|
|
|
|
|
const off = addr - base;
|
|
|
|
|
if (off === GPIO_OUTDR) {
|
|
|
|
|
const prev = this.gpioOutdr[base];
|
|
|
|
|
this.gpioOutdr[base] = val;
|
|
|
|
|
const changed = prev ^ val;
|
|
|
|
|
if (changed) {
|
|
|
|
|
const timeMs = (this.core.cycles / CPU_HZ) * 1000;
|
|
|
|
|
for (let bit = 0; bit < 8; bit++) {
|
|
|
|
|
if (changed & (1 << bit)) {
|
|
|
|
|
const pin = pinOffset + bit;
|
|
|
|
|
const state = !!(val & (1 << bit));
|
|
|
|
|
this.onPinChangeWithTime?.(pin, state, timeMs);
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
this.pinManager.setPinState(pin, state, 'mcu');
|
2026-03-16 01:39:26 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── HEX loading ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
loadHex(hexContent: string): void {
|
|
|
|
|
// Reset flash region
|
|
|
|
|
const mem = (this.core as unknown as { mem: Uint8Array }).mem;
|
|
|
|
|
mem.fill(0, 0, FLASH_SIZE);
|
|
|
|
|
|
|
|
|
|
const bytes = hexToUint8Array(hexContent);
|
|
|
|
|
const maxCopy = Math.min(bytes.length, FLASH_SIZE);
|
|
|
|
|
mem.set(bytes.subarray(0, maxCopy), 0);
|
|
|
|
|
|
|
|
|
|
this.core.reset(FLASH_BASE);
|
|
|
|
|
console.log(`[RiscV] Loaded ${maxCopy} bytes`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
start(): void {
|
|
|
|
|
if (this.running) return;
|
|
|
|
|
this.running = true;
|
|
|
|
|
this._loop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
this.running = false;
|
|
|
|
|
cancelAnimationFrame(this.animFrameId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
this.stop();
|
|
|
|
|
this.rxFifo = [];
|
|
|
|
|
this.gpioOutdr = { [GPIOA_BASE]: 0, [GPIOC_BASE]: 0, [GPIOD_BASE]: 0 };
|
|
|
|
|
this.core.reset(FLASH_BASE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
serialWrite(text: string): void {
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
this.rxFifo.push(text.charCodeAt(i));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPinState(_pin: number, _state: boolean): void {
|
|
|
|
|
// Input pin injection not yet implemented for RISC-V GPIO
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isRunning(): boolean {
|
|
|
|
|
return this.running;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Execution loop ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private _loop(): void {
|
|
|
|
|
if (!this.running) return;
|
|
|
|
|
for (let i = 0; i < CYCLES_PER_FRAME; i++) {
|
|
|
|
|
this.core.step();
|
|
|
|
|
}
|
|
|
|
|
this.animFrameId = requestAnimationFrame(() => this._loop());
|
|
|
|
|
}
|
|
|
|
|
}
|