From d5b9a9ceb51d766841d4c8164a25c803bcbe298a Mon Sep 17 00:00:00 2001 From: David Montero Date: Wed, 3 Jun 2026 21:04:17 +0200 Subject: [PATCH] feat(custom-chip): run custom chips with no board (general-purpose sim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Velxio can now simulate one or more custom-chip CPUs with NO Arduino/ESP32 board on the canvas — a general-purpose electronics simulator, not an MCU-only one. - DynamicComponent: board-less parts get the real shared flat PinManager (instead of a no-op stub) so a custom chip's digital pin writes/reads reach the LEDs/inputs wired to it. - CustomChipPart: the rAF tick respects board-less Run/Stop (freezes while the electrical sim is paused); board behaviour is unchanged. - EditorToolbar.handleRun: board-less Run compiles each chip's WASM/ROM and re-attaches the parts (restartParts) so they pick up the fresh WASM, then resumes the solver. - useSimulatorStore.restartParts(): bump hexEpoch to force part re-attach. - New example "Z80 Larson Scanner (no board)": a programmable Z80 + 8 LEDs + the adjustable power-supply component, no MCU. The chip drives the LEDs through the synthetic-pin + ngspice path added earlier. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/DynamicComponent.tsx | 15 +++- .../src/components/editor/EditorToolbar.tsx | 28 +++++- frontend/src/data/examples-retro-intel.ts | 90 +++++++++++++++++++ .../src/simulation/parts/CustomChipPart.ts | 17 +++- frontend/src/store/useSimulatorStore.ts | 5 ++ 5 files changed, 144 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 8a99e737..9d482aeb 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -427,10 +427,17 @@ export const DynamicComponent: React.FC = ({ ({ setPinState: () => {}, isRunning: () => false, - pinManager: { - onPinChange: () => () => {}, - triggerPinChange: () => {}, - } as any, + // Board-less circuits have no MCU simulator, but a custom chip still + // needs a real PinManager so its digital pin writes/reads reach the + // components wired to it (LEDs, buttons, other chips). Hand it the + // shared flat PinManager that SimulatorCanvas subscribes LEDs to, so + // both sides talk on the same numeric/synthetic pin ids. Falls back + // to a no-op only if even that isn't ready yet. + pinManager: + (useSimulatorStore.getState().pinManager as any) ?? { + onPinChange: () => () => {}, + triggerPinChange: () => {}, + }, } as any); // Helper to find Arduino pin connected to a component pin. // Traces through electrically-transparent passive components so that a diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 55a4bcc2..5267fa00 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -610,10 +610,32 @@ export const EditorToolbar = ({ if (!ok) return; } - // Board-less circuits (SPICE-only digital / analog gallery) have no MCU - // to start. Resuming the electrical solver replays any switch toggles - // captured while paused so the canvas catches up instantly. + // Board-less circuits have no MCU to start. If there are custom-chip CPUs + // on the canvas, compile them (WASM + ROM) and re-attach so they pick up + // the fresh WASM — Velxio runs custom chips with no Arduino/ESP32 board, + // as a general-purpose electronics simulator. Then resume the electrical + // solver (replays any switch toggles captured while paused). if (isBoardless) { + const customChips = useSimulatorStore + .getState() + .components.filter((c) => c.metadataId === 'custom-chip'); + if (customChips.length > 0) { + setCompiling(true); + setConsoleOpen(true); + setCompileLogs([]); + try { + await prepareCustomChips(customChips, files); + } catch (e) { + addLog({ + timestamp: new Date(), + type: 'error', + message: e instanceof Error ? e.message : String(e), + }); + } + setCompiling(false); + // Force the chip parts to re-attach with their freshly compiled WASM. + useSimulatorStore.getState().restartParts(); + } setElectricalPaused(false); setMessage(null); return; diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index 20574eb4..3724c46a 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -621,4 +621,94 @@ export const retroIntelExamples: ExampleProject[] = [ }, ], }, + + // ── Z80 Larson Scanner — NO board (general-purpose electronics) ── + // A programmable Z80 chip + 8 LEDs + a regulated power supply, with NO + // Arduino/ESP32 on the canvas. Demonstrates that Velxio runs custom chips + // standalone. Board-less (boardFilter: 'digital'); the ROM is pre-baked so + // only the chip WASM compiles on Run. + { + id: 'z80-larson-no-board', + title: 'Z80 Larson Scanner (no board)', + description: + 'The programmable Z80 chip drives 8 LEDs with NO Arduino — powered by a ' + + 'regulated bench supply. Velxio runs custom chips as a general-purpose ' + + 'electronics simulator. Click Run: a single LED walks back and forth.', + category: 'circuits', + difficulty: 'intermediate', + boardFilter: 'digital', + tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'no-board', 'power-supply', 'custom-chip', 'spice', 'programmable'], + code: `// Z80 Larson Scanner — NO Arduino, just the chip + a power supply.\n//\n// Velxio runs custom chips as a general-purpose electronics simulator: the\n// programmable Z80 on the canvas executes its ROM and drives the 8 LEDs\n// directly, powered by the regulated supply (no MCU needed). Click Run.\n\nvoid setup() {}\nvoid loop() {}\n`, + components: [ + { + type: 'power-supply', + id: 'psu', + x: 180, + y: 200, + properties: { mode: 'dc', voltage: 5, currentLimit: 2, frequency: 50 }, + }, + { + type: 'custom-chip', + id: 'z80cpu', + x: 440, + y: 150, + properties: { + chipName: 'Z80 CPU (programmable)', + sourceC: z80CpuC, + chipJson: z80CpuJ, + wasmBase64: '', + // Pre-baked larson.s ROM (LD SP,0xBFFF; rotate a bit across 0xC000). + romBytes: 'Mf+/PgEyAMD1zRAA8QcY9Q5QBgAQ/g0g+ck=', + programFile: '', + programTarget: 'z80', + }, + }, + ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ + type: 'wokwi-resistor', + id: `r-${i}`, + x: 760, + y: 110 + i * 50, + properties: { value: '220' }, + })), + ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ + type: 'wokwi-led', + id: `led-${i}`, + x: 900, + y: 110 + i * 50, + properties: { color: i % 2 === 0 ? 'red' : 'orange' }, + })), + ], + wires: [ + { + id: 'psu-vcc', + start: { componentId: 'psu', pinName: 'SIG' }, + end: { componentId: 'z80cpu', pinName: 'VCC' }, + color: '#e74c3c', + }, + { + id: 'psu-gnd', + start: { componentId: 'psu', pinName: 'GND' }, + end: { componentId: 'z80cpu', pinName: 'GND' }, + color: '#000000', + }, + ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ + id: `w-led-${i}`, + start: { componentId: 'z80cpu', pinName: `LED${i}` }, + end: { componentId: `r-${i}`, pinName: '1' }, + color: '#facc15', + })), + ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ + id: `w-r-${i}`, + start: { componentId: `r-${i}`, pinName: '2' }, + end: { componentId: `led-${i}`, pinName: 'A' }, + color: '#facc15', + })), + ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ + id: `w-gnd-${i}`, + start: { componentId: `led-${i}`, pinName: 'C' }, + end: { componentId: 'psu', pinName: 'GND' }, + color: '#000000', + })), + ], + }, ]; diff --git a/frontend/src/simulation/parts/CustomChipPart.ts b/frontend/src/simulation/parts/CustomChipPart.ts index 6c207d2d..16fbf2d9 100644 --- a/frontend/src/simulation/parts/CustomChipPart.ts +++ b/frontend/src/simulation/parts/CustomChipPart.ts @@ -19,6 +19,7 @@ import { detectSimulatorKind, } from '../customChips'; import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { useElectricalStore } from '../../store/useElectricalStore'; import { clearChipDrives } from '../customChips/chipPinDrives'; import { requestElectricalResolve } from '../spice/electricalResolveHook'; @@ -202,10 +203,18 @@ PartSimulationRegistry.register('custom-chip', { // exposes the same epoch via vx_sim_now_nanos. const tick = () => { if (disposed || !instance) return; - try { - instance.tickTimers(BigInt(Math.floor(performance.now() * 1_000_000))); - } catch (e) { - console.error(`[custom-chip:${componentId}] tickTimers threw:`, e); + // When there is no board, the chip is driven by the editor's Run / + // Stop, which toggle the electrical "paused" flag — freeze the chip + // while paused (but keep the rAF alive so Run resumes instantly). + // With a board present, behaviour is unchanged: tick whenever attached. + const boardless = useSimulatorStore.getState().boards.length === 0; + const runnable = !boardless || !useElectricalStore.getState().paused; + if (runnable) { + try { + instance.tickTimers(BigInt(Math.floor(performance.now() * 1_000_000))); + } catch (e) { + console.error(`[custom-chip:${componentId}] tickTimers threw:`, e); + } } rafHandle = requestAnimationFrame(tick); }; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 36373cf1..c24d2483 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -827,6 +827,9 @@ interface SimulatorState { startSimulation: () => void; stopSimulation: () => void; resetSimulation: () => void; + /** Bump hexEpoch to force every component part to re-attach (e.g. so a + * board-less custom chip picks up freshly compiled WASM). */ + restartParts: () => void; setCompiledHex: (hex: string) => void; setCompiledBinary: (base64: string) => void; setRunning: (running: boolean) => void; @@ -2139,6 +2142,8 @@ export const useSimulatorStore = create((set, get) => { get().startBoard(boardId); }, + restartParts: () => set((s) => ({ hexEpoch: s.hexEpoch + 1 })), + stopSimulation: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID;