feat(custom-chip): run custom chips with no board (general-purpose sim)

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 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-03 21:04:17 +02:00
parent a57951d854
commit d5b9a9ceb5
5 changed files with 144 additions and 11 deletions

View File

@ -427,10 +427,17 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
({
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

View File

@ -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;

View File

@ -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',
})),
],
},
];

View File

@ -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);
};

View File

@ -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<SimulatorState>((set, get) => {
get().startBoard(boardId);
},
restartParts: () => set((s) => ({ hexEpoch: s.hexEpoch + 1 })),
stopSimulation: () => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;