diff --git a/frontend/src/__tests__/load-example-transitions.test.ts b/frontend/src/__tests__/load-example-transitions.test.ts index 3de157b9..2cf2ea73 100644 --- a/frontend/src/__tests__/load-example-transitions.test.ts +++ b/frontend/src/__tests__/load-example-transitions.test.ts @@ -16,7 +16,7 @@ import { useEditorStore } from '../store/useEditorStore'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { useElectricalStore } from '../store/useElectricalStore'; import { loadExample } from '../utils/loadExample'; -import { exampleProjects } from '../data/examples'; +import { exampleProjects, type ExampleProject } from '../data/examples'; import { isProgrammableChip } from '../services/romCompileService'; function resetStores() { @@ -108,21 +108,54 @@ describe('loadExample — programmable-chip program lives in its own group', () expect(activeSketchContent(), 'editor shows the larson.s program').toBe(larson?.content); }); - it('board + chip example keeps the chip program OUT of the board sketch group', async () => { - await loadExample(findExample('z80-larson-scanner')); + it('board + chip routing keeps the chip program OUT of the board sketch group', async () => { + // The gallery's chip examples are all board-less now, but the board+chip + // routing path still applies when a user drops a programmable chip onto a + // board project — exercise it with an inline synthetic example. + const boardChip: ExampleProject = { + id: 'test-board-chip', + title: 'Board + chip', + description: '', + category: 'circuits', + difficulty: 'beginner', + boardType: 'arduino-uno', + code: 'void setup(){}\nvoid loop(){}', + files: [ + { name: 'sketch.ino', content: 'void setup(){}\nvoid loop(){}' }, + { name: 'prog.s', content: '; chip program' }, + ], + components: [ + { + type: 'custom-chip', + id: 'tchip', + x: 100, + y: 100, + properties: { + chipName: 'T', + sourceC: '', + chipJson: JSON.stringify({ programTargets: ['z80'] }), + programFile: 'prog.s', + programTarget: 'z80', + }, + }, + ], + wires: [], + } as unknown as ExampleProject; + + await loadExample(boardChip); const ed = useEditorStore.getState(); const sim = useSimulatorStore.getState(); - // Board group shows only the sketch — larson.s is NOT a sibling tab. + // Board group shows only the sketch — prog.s is NOT a sibling tab. const board = sim.boards.find((b) => b.id === sim.activeBoardId) ?? sim.boards[0]; const boardFiles = (ed.fileGroups[board.activeFileGroupId] ?? []).map((f) => f.name); expect(boardFiles).toContain('sketch.ino'); - expect(boardFiles, 'larson.s must not pollute the board group').not.toContain('larson.s'); + expect(boardFiles, 'prog.s must not pollute the board group').not.toContain('prog.s'); // The chip program lives in its own group instead. - const chipGroupId = 'group-chip-z80cpu'; - expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('larson.s'); + const chipGroupId = 'group-chip-tchip'; + expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('prog.s'); // With a board present the board sketch stays the active group. expect(ed.activeGroupId).toBe(board.activeFileGroupId); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 16d148bd..4c2700a9 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -18,6 +18,8 @@ import { targetForChip, } from '../../services/romCompileService'; import { compileChip } from '../../services/chipCompileService'; +import { clearChipDrives } from '../../simulation/customChips/chipPinDrives'; +import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook'; import { reportRunEvent } from '../../services/metricsService'; import { useProjectStore } from '../../store/useProjectStore'; import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; @@ -36,6 +38,24 @@ import { } from '../../utils/analytics'; import './EditorToolbar.css'; +/** + * Clear the output drives of every custom chip on the canvas and re-solve, so + * chip-driven LEDs go dark on Stop. A chip drives its nets via its own SPICE + * voltage sources (registered in chipPinDrives); stopBoard / electrical-pause + * don't touch those, so without this the LEDs would freeze at their last frame. + */ +function clearAllChipDrives(): void { + const comps = useSimulatorStore.getState().components; + let any = false; + for (const c of comps) { + if (c.metadataId === 'custom-chip') { + clearChipDrives(c.id); + any = true; + } + } + if (any) requestElectricalResolve(); +} + interface EditorToolbarProps { consoleOpen: boolean; setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void; @@ -817,14 +837,18 @@ export const EditorToolbar = ({ const handleStop = () => { trackStopSimulation(); if (isBoardless) { - // Freeze the SPICE solver — every LED stays at its current brightness - // and switch clicks stop re-triggering ngspice until the user hits Run. + // Freeze the chip tick (the paused flag) AND clear the chip's output + // drives so its LEDs go dark on Stop — not frozen at their last frame. setElectricalPaused(true); + clearAllChipDrives(); setMessage(null); return; } if (activeBoardId) stopBoard(activeBoardId); else stopSimulation(); + // A chip wired to a board drives its LEDs via its own SPICE sources, which + // stopBoard doesn't touch — clear them so those LEDs also go dark. + clearAllChipDrives(); setMessage(null); }; diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index 68c5e927..0bbf2e84 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -108,20 +108,37 @@ inner: RET `; -const larsonZ80Sketch = `// Z80 Larson Scanner -- Arduino Uno companion sketch. -// -// The Z80 chip on the canvas runs the larson.s program. This Arduino -// sketch just keeps Serial alive in case you wire UART later. -// -// Just click Run. Velxio automatically compiles the z80-cpu chip to WASM, -// assembles larson.s into a Z80 ROM, loads it into the chip, and starts the -// simulation. A single bit then walks across the 8 LEDs. -// -// To slow it down or speed it up: change the "LD C, 80" line in larson.s -// (higher number = slower), then hit Run again. +const larsonScannerAsm = `; Z80 "comet" scanner — a TWO-LED pair sweeps across the 8 LEDs. +; +; Same z80-cpu chip as the single-bit Larson, but the start pattern is +; 0x03 (two adjacent LEDs) and the sweep is faster. RLCA rotates the pair +; left and wraps it around the ends — a brighter, quicker Knight Rider. +; To slow it down, raise the "LD C, 40" delay; to widen the comet, change +; the "LD A, 0x03" start pattern. -void setup() {} -void loop() {} + ORG 0x0000 + + LD SP, 0xBFFF ; stack at top of RAM + LD A, 0x03 ; A = two adjacent LEDs (LED0 + LED1) + +loop: + LD (0xC000), A ; write to LED port + PUSH AF + CALL delay + POP AF + RLCA ; rotate the pair left (wraps at the ends) + JR loop + +; -- delay: faster sweep than the single-bit Larson --------------------- +delay: + LD C, 40 +outer: + LD B, 0 ; 0 = 256 inner iterations +inner: + DJNZ inner + DEC C + JR NZ, outer + RET `; const killbitsAsm = `; Kill the Bit -- Dean McDaniel, May 15, 1975. Public domain. @@ -158,23 +175,6 @@ beat: JMP beat `; -const killbitsSketch = `// Kill the Bit -- Arduino Uno companion sketch. -// -// All the action happens in the i8080-cpu chip on the canvas. This sketch -// just keeps Serial alive in case you wire UART later. Watch the LEDs and -// press the matching button on each beat. -// -// Steps: -// 1. Open killbits.s in the editor. -// 2. Click Compile -- the backend assembles the 28-byte ROM and injects -// it into the i8080-cpu chip's romBytes property. -// 3. Click Run. A single LED will start walking across the 8 outputs. -// 4. Press the button (BTN0..BTN7) below the lit LED to "kill" the bit. - -void setup() {} -void loop() {} -`; - const replSketch = `// i8080 banner streamer // // The bundled i8080 chip on the canvas runs its own embedded ROM. All @@ -202,26 +202,13 @@ void loop() { } `; -const counterSketch = `// i8080 button counter +const counterNote = `// Intel 8080 Button Counter — self-contained, no board. // -// The bundled i8080 chip on the canvas runs its own embedded ROM that -// increments a counter on every BTN_INC press and clears it on BTN_RST. -// The current value drives LED0..LED7 in binary. -// -// Steps: -// 1. Double-click the chip, switch to the Editor tab, click Compile. -// 2. Hit Save, then Run. -// 3. Click BTN_INC repeatedly to count up. Click BTN_RST to clear. -// The 8 LEDs show the current count in binary. -// -// The Arduino sketch itself does nothing — the 8080 is the brain of -// this little board. - -void setup() { -} - -void loop() { -} +// This chip runs its own embedded ROM: BTN_INC counts up in binary on the +// 8 LEDs, BTN_RST clears. There is no separate program file to edit here — +// the program lives inside the chip (double-click it, Editor tab, to view +// its C). Powered by the regulated bench supply. Click Run, then press the +// buttons. `; export const retroIntelExamples: ExampleProject[] = [ @@ -289,15 +276,22 @@ export const retroIntelExamples: ExampleProject[] = [ 'inside the single chip on the canvas.', category: 'circuits', difficulty: 'intermediate', - boardType: 'arduino-uno', - tags: ['retro', '8080', 'cpu', 'leds', 'buttons', 'wasm', 'custom-chip'], - code: counterSketch, + boardFilter: 'digital', + tags: ['retro', '8080', 'cpu', 'leds', 'buttons', 'no-board', 'power-supply', 'wasm', 'custom-chip', 'spice'], + code: counterNote, components: [ + { + type: 'power-supply', + id: 'psu', + x: 180, + y: 240, + properties: { mode: 'dc', voltage: 5, currentLimit: 2, frequency: 50 }, + }, { type: 'custom-chip', id: 'i8080c', - x: 380, - y: 120, + x: 440, + y: 150, properties: { chipName: 'i8080 Button Counter', sourceC: i8080CounterC, @@ -305,46 +299,70 @@ export const retroIntelExamples: ExampleProject[] = [ wasmBase64: '', }, }, - // 8 LEDs for the binary readout + // 8 LEDs for the binary readout, each through a series resistor + ...[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: 700 + i * 50, - y: 120, + x: 900, + y: 110 + i * 50, properties: { color: i < 4 ? 'red' : 'orange' }, })), // 2 buttons { type: 'wokwi-pushbutton', id: 'btn-inc', - x: 380, - y: 420, + x: 300, + y: 470, properties: { color: 'green' }, }, { type: 'wokwi-pushbutton', id: 'btn-rst', - x: 540, - y: 420, + x: 480, + y: 470, properties: { color: 'red' }, }, ], wires: [ - // LED data wires + { + id: 'psu-vcc', + start: { componentId: 'psu', pinName: 'SIG' }, + end: { componentId: 'i8080c', pinName: 'VCC' }, + color: '#e74c3c', + }, + { + id: 'psu-gnd', + start: { componentId: 'psu', pinName: 'GND' }, + end: { componentId: 'i8080c', pinName: 'GND' }, + color: '#000000', + }, + // LED data wires: chip -> resistor -> LED anode -> supply GND ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `wire-led-${i}`, + id: `w-led-${i}`, start: { componentId: 'i8080c', 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', })), - // LED GNDs ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `wire-led-${i}-gnd`, + id: `w-gnd-${i}`, start: { componentId: `led-${i}`, pinName: 'C' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, + end: { componentId: 'psu', pinName: 'GND' }, color: '#000000', })), - // Buttons + // Buttons: signal to the chip, other side to the supply rail { id: 'wire-btn-inc', start: { componentId: 'btn-inc', pinName: '1.l' }, @@ -360,27 +378,15 @@ export const retroIntelExamples: ExampleProject[] = [ { id: 'wire-btn-inc-pwr', start: { componentId: 'btn-inc', pinName: '2.r' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, + end: { componentId: 'psu', pinName: 'SIG' }, color: '#e74c3c', }, { id: 'wire-btn-rst-pwr', start: { componentId: 'btn-rst', pinName: '2.r' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, + end: { componentId: 'psu', pinName: 'SIG' }, color: '#e74c3c', }, - { - id: 'wire-i8080c-vcc', - start: { componentId: 'i8080c', pinName: 'VCC' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, - color: '#e74c3c', - }, - { - id: 'wire-i8080c-gnd', - start: { componentId: 'i8080c', pinName: 'GND' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, - color: '#000000', - }, ], }, @@ -389,24 +395,29 @@ export const retroIntelExamples: ExampleProject[] = [ id: 'i8080-killbits', title: 'Kill the Bit (1975)', description: - "Dean McDaniel's iconic 1975 Altair 8800 reflex game, running on a programmable Intel 8080 chip. " + - 'A single LED walks across 8 LEDs; press the matching button at the right moment to kill it. ' + - 'The ROM lives in killbits.s as a project file — click Compile to assemble it, then Run.', + "Dean McDaniel's iconic 1975 Altair 8800 reflex game, running on a programmable Intel 8080 chip " + + 'with NO Arduino — powered by a regulated bench supply. A single LED walks across 8 LEDs; press ' + + 'the matching button at the right moment to kill it. The ROM is killbits.s (the chip\'s editable ' + + 'program); click Run.', category: 'games', difficulty: 'advanced', - boardType: 'arduino-uno', - tags: ['retro', '8080', 'cpu', 'leds', 'buttons', 'game', 'altair', 'wasm', 'custom-chip', 'asm', 'programmable'], - code: killbitsSketch, - files: [ - { name: 'sketch.ino', content: killbitsSketch }, - { name: 'killbits.s', content: killbitsAsm }, - ], + boardFilter: 'digital', + tags: ['retro', '8080', 'cpu', 'leds', 'buttons', 'game', 'altair', 'no-board', 'power-supply', 'wasm', 'custom-chip', 'asm', 'spice', 'programmable'], + code: killbitsAsm, + files: [{ name: 'killbits.s', content: killbitsAsm }], components: [ + { + type: 'power-supply', + id: 'psu', + x: 180, + y: 240, + properties: { mode: 'dc', voltage: 5, currentLimit: 2, frequency: 50 }, + }, { type: 'custom-chip', id: 'i8080cpu', - x: 380, - y: 120, + x: 440, + y: 150, properties: { chipName: 'i8080 CPU (programmable)', sourceC: i8080CpuC, @@ -416,32 +427,57 @@ export const retroIntelExamples: ExampleProject[] = [ programFile: 'killbits.s', }, }, + ...[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: 700 + i * 50, - y: 120, + x: 900, + y: 110 + i * 50, properties: { color: i < 4 ? 'yellow' : 'orange' }, })), ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ type: 'wokwi-pushbutton', id: `btn-${i}`, - x: 700 + i * 50, - y: 380, + x: 1080, + y: 110 + i * 50, properties: { color: 'green' }, })), ], wires: [ + { + id: 'psu-vcc', + start: { componentId: 'psu', pinName: 'SIG' }, + end: { componentId: 'i8080cpu', pinName: 'VCC' }, + color: '#e74c3c', + }, + { + id: 'psu-gnd', + start: { componentId: 'psu', pinName: 'GND' }, + end: { componentId: 'i8080cpu', pinName: 'GND' }, + color: '#000000', + }, ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `wire-led-${i}`, + id: `w-led-${i}`, start: { componentId: 'i8080cpu', 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: `wire-led-${i}-gnd`, + id: `w-gnd-${i}`, start: { componentId: `led-${i}`, pinName: 'C' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, + end: { componentId: 'psu', pinName: 'GND' }, color: '#000000', })), ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ @@ -453,89 +489,95 @@ export const retroIntelExamples: ExampleProject[] = [ ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ id: `wire-btn-${i}-pwr`, start: { componentId: `btn-${i}`, pinName: '2.r' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, + end: { componentId: 'psu', pinName: 'SIG' }, color: '#e74c3c', })), - { - id: 'wire-cpu-vcc', - start: { componentId: 'i8080cpu', pinName: 'VCC' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, - color: '#e74c3c', - }, - { - id: 'wire-cpu-gnd', - start: { componentId: 'i8080cpu', pinName: 'GND' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, - color: '#000000', - }, ], }, // ── Z80 Larson Scanner — first example on the programmable z80-cpu ── { id: 'z80-larson-scanner', - title: 'Z80 Larson Scanner', + title: 'Z80 Comet Scanner', description: - 'A single LED walks left across 8 LEDs, Knight-Rider style. Z80 program lives in larson.s ' + - '(JR + DJNZ + RLCA — instructions the 8080 emulator can\'t run). Click Compile, then Run.', + 'A faster, TWO-LED "comet" sweeps across 8 LEDs on a programmable Z80 chip — a brighter twist on ' + + 'the single-bit Larson. Written in Z80 assembly (scanner.s), NO Arduino: the chip runs standalone ' + + 'on a regulated supply. Click Run.', category: 'circuits', difficulty: 'intermediate', - boardType: 'arduino-uno', - tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'knight-rider', 'wasm', 'custom-chip', 'asm', 'programmable'], - code: larsonZ80Sketch, - files: [ - { name: 'sketch.ino', content: larsonZ80Sketch }, - { name: 'larson.s', content: larsonZ80Asm }, - ], + boardFilter: 'digital', + tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'knight-rider', 'comet', 'no-board', 'power-supply', 'wasm', 'custom-chip', 'asm', 'spice', 'programmable'], + code: larsonScannerAsm, + files: [{ name: 'scanner.s', content: larsonScannerAsm }], 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: 380, - y: 120, + x: 440, + y: 150, properties: { chipName: 'Z80 CPU (programmable)', sourceC: z80CpuC, chipJson: z80CpuJ, wasmBase64: '', romBytes: '', - programFile: 'larson.s', + programFile: 'scanner.s', 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: 700 + i * 50, - y: 120, - properties: { color: i % 2 === 0 ? 'red' : 'orange' }, + x: 900, + y: 110 + i * 50, + properties: { color: i % 2 === 0 ? 'green' : 'blue' }, })), ], wires: [ - ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `wire-led-${i}`, - start: { componentId: 'z80cpu', pinName: `LED${i}` }, - end: { componentId: `led-${i}`, pinName: 'A' }, - color: '#ef4444', - })), - ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ - id: `wire-led-${i}-gnd`, - start: { componentId: `led-${i}`, pinName: 'C' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, - color: '#000000', - })), { - id: 'wire-z80-vcc', - start: { componentId: 'z80cpu', pinName: 'VCC' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, + id: 'psu-vcc', + start: { componentId: 'psu', pinName: 'SIG' }, + end: { componentId: 'z80cpu', pinName: 'VCC' }, color: '#e74c3c', }, { - id: 'wire-z80-gnd', - start: { componentId: 'z80cpu', pinName: 'GND' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, + 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: '#22c55e', + })), + ...[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: '#22c55e', + })), + ...[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 16fbf2d9..1b4391d9 100644 --- a/frontend/src/simulation/parts/CustomChipPart.ts +++ b/frontend/src/simulation/parts/CustomChipPart.ts @@ -203,12 +203,18 @@ PartSimulationRegistry.register('custom-chip', { // exposes the same epoch via vx_sim_now_nanos. const tick = () => { if (disposed || !instance) return; - // 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; + // Freeze the chip while the simulation is stopped — but keep the rAF + // alive so Run resumes instantly. + // - Board-less: driven by the editor Run/Stop via the electrical + // "paused" flag. + // - With board(s): the chip must ALSO stop when the user hits Stop, + // which sets board.running=false. Gating only on board presence + // (the old `!boardless`) left the chip ticking forever after Stop. + const simState = useSimulatorStore.getState(); + const boardless = simState.boards.length === 0; + const runnable = boardless + ? !useElectricalStore.getState().paused + : simState.boards.some((b) => b.running); if (runnable) { try { instance.tickTimers(BigInt(Math.floor(performance.now() * 1_000_000)));