diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 8218db49..55a4bcc2 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -17,6 +17,7 @@ import { formatForFile, targetForChip, } from '../../services/romCompileService'; +import { compileChip } from '../../services/chipCompileService'; import { reportRunEvent } from '../../services/metricsService'; import { useProjectStore } from '../../store/useProjectStore'; import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; @@ -195,6 +196,120 @@ export const EditorToolbar = ({ [setCompileLogs], ); + /** + * Make every custom-chip on the canvas runnable: compile its C source to + * WASM (when it has none yet) and, for programmable CPU chips, assemble or + * compile the program file it references into ROM bytes — stashing both on + * the chip component's `properties` so the next simulation start picks them + * up. Non-fatal by design: a chip that fails to compile is logged and + * skipped so the board itself still runs. + */ + const prepareCustomChips = useCallback( + async ( + chips: { id: string; properties: Record }[], + boardFiles: { name: string; content: string }[], + ) => { + const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile; + const updateComponent = useSimulatorStore.getState().updateComponent; + + for (const chip of chips) { + // Re-read the freshest properties each iteration (an earlier chip's + // update doesn't touch this one, but be defensive). + const live = useSimulatorStore.getState().components.find((c) => c.id === chip.id); + const props = { ...(live?.properties ?? chip.properties) } as Record; + const chipLabel = String(props.chipName ?? 'custom chip'); + const sourceC = String(props.sourceC ?? ''); + const chipJson = String(props.chipJson ?? '{}'); + let changed = false; + + // 1. C -> WASM. Only when missing — the chip designer fills this too. + if (!String(props.wasmBase64 ?? '') && sourceC) { + addLog({ + timestamp: new Date(), + type: 'info', + message: `Compiling chip "${chipLabel}" to WASM...`, + }); + try { + const r = await compileChip(sourceC, chipJson); + if (r.success && r.wasm_base64) { + props.wasmBase64 = r.wasm_base64; + changed = true; + addLog({ + timestamp: new Date(), + type: 'success', + message: `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`, + }); + } else { + addLog({ + timestamp: new Date(), + type: 'error', + message: `Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`, + }); + } + } catch (e) { + addLog({ + timestamp: new Date(), + type: 'error', + message: `Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`, + }); + } + } + + // 2. program file -> ROM bytes (programmable CPU chips). Recompile + // when there's no ROM yet or the user edited code since last build. + const programFile = String(props.programFile ?? '').trim(); + if (programFile && (!String(props.romBytes ?? '') || codeChanged)) { + const file = boardFiles.find((f) => f.name === programFile); + if (!file) { + addLog({ + timestamp: new Date(), + type: 'error', + message: `Chip "${chipLabel}": program file "${programFile}" not found in this board's files.`, + }); + } else { + const target = targetForChip(chipJson); + const fmt = formatForFile(programFile); + addLog({ + timestamp: new Date(), + type: 'info', + message: `Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`, + }); + try { + const rr = await compileRom(file.content, target, fmt); + if (rr.success && rr.rom_base64) { + props.romBytes = rr.rom_base64; + props.programFile = programFile; + changed = true; + addLog({ + timestamp: new Date(), + type: 'success', + message: `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`, + }); + } else { + addLog({ + timestamp: new Date(), + type: 'error', + message: `ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`, + }); + } + } catch (e) { + addLog({ + timestamp: new Date(), + type: 'error', + message: `ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`, + }); + } + } + } + + if (changed) { + updateComponent(chip.id, { properties: props } as any); + } + } + }, + [addLog], + ); + const handleCompile = async () => { setCompiling(true); setMessage(null); @@ -205,100 +320,35 @@ export const EditorToolbar = ({ setCompileLogs([]); trackCompileCode(); - // ── Chip-program path ─────────────────────────────────────────────── - // If the editor's active file is a chip-program file we don't compile - // Arduino code — we assemble/compile it into ROM bytes via - // /api/compile-rom and stash the result on every custom-chip component - // that points at this filename through its `programFile` property. The - // chip's emulator then reads the bytes on chip_setup via vx_rom_size / - // vx_rom_read. + // ── Custom-chip preparation ───────────────────────────────────────── + // Any custom-chip on the canvas is made "live" here so a single + // Compile / Run is enough — no separate trip through the chip designer + // or a manual ROM compile. For every custom-chip we: + // 1. compile its C source to WASM (when it has none yet), and + // 2. for programmable CPU chips, assemble/compile the program file it + // points at (larson.s, chaser.c, …) into ROM bytes. + // Both artefacts are stashed on the chip component's `properties`; + // CustomChipPart reads wasmBase64 + romBytes at simulation start. // - // A file is "chip program" when EITHER its extension is unambiguous - // (.s/.asm/.hex/.bin) OR some custom-chip on the canvas has - // programFile === activeFile.name. The latter lets .c files route to - // SDCC instead of arduino-cli when wired to a CPU chip. - const activeFile = files.find((f) => f.id === useEditorStore.getState().activeFileId); + // The chip program files are ALSO kept out of the Arduino sketch compile + // below (see `chipProgramFiles`) — otherwise arduino-cli/avr-gcc would + // try to build e.g. chaser.c and choke on SDCC-only syntax such as + // `__at(0xC000)`, which is exactly what broke the Z80 examples. const componentsForCompile = useSimulatorStore.getState().components; - const chipsBoundToFile = activeFile - ? componentsForCompile.filter((c) => { - if (c.metadataId !== 'custom-chip') return false; - const prog = String((c.properties as any)?.programFile ?? '').trim(); - return prog === activeFile.name; - }) - : []; - - if (activeFile && (isChipProgramFile(activeFile.name) || chipsBoundToFile.length > 0)) { - try { - const chips = chipsBoundToFile.length > 0 - ? chipsBoundToFile - : componentsForCompile.filter((c) => { - if (c.metadataId !== 'custom-chip') return false; - const prog = String((c.properties as any)?.programFile ?? '').trim(); - return prog === '' || prog === activeFile.name; - }); - if (chips.length === 0) { - addLog({ - timestamp: new Date(), - type: 'error', - message: `No custom-chip on the canvas references ${activeFile.name}. Drop an "i8080 CPU" chip, or set its programFile property.`, - }); - setMessage({ type: 'error', text: 'No matching custom-chip on canvas' }); - setCompiling(false); - return; - } - // Resolve target from the first matching chip's chip.json. - const firstChipJson = String((chips[0].properties as any)?.chipJson ?? '{}'); - const target = targetForChip(firstChipJson); - const fmt = formatForFile(activeFile.name); - addLog({ - timestamp: new Date(), - type: 'info', - message: `Assembling ${activeFile.name} (target=${target}, format=${fmt}) for ${chips.length} chip(s)...`, - }); - const result = await compileRom(activeFile.content, target, fmt); - if (!result.success || !result.rom_base64) { - addLog({ - timestamp: new Date(), - type: 'error', - message: result.error || 'ROM compile failed', - }); - if (result.stderr) { - addLog({ timestamp: new Date(), type: 'error', message: result.stderr }); - } - setMessage({ type: 'error', text: result.error || 'ROM compile failed' }); - setCompiling(false); - return; - } - // Inject into every matching chip's romBytes property. - const updateComponent = useSimulatorStore.getState().updateComponent; - for (const chip of chips) { - updateComponent(chip.id, { - properties: { - ...(chip.properties as Record), - romBytes: result.rom_base64, - programFile: activeFile.name, - }, - }); - } - addLog({ - timestamp: new Date(), - type: 'success', - message: `ROM compiled: ${result.byte_size} bytes injected into ${chips.length} chip(s).`, - }); - setMessage({ - type: 'success', - text: `ROM ready (${result.byte_size} B). Hit Run.`, - }); - } catch (e) { - const errMsg = e instanceof Error ? e.message : String(e); - addLog({ timestamp: new Date(), type: 'error', message: errMsg }); - setMessage({ type: 'error', text: errMsg }); - } finally { - setCompiling(false); - } - return; + const customChips = componentsForCompile.filter((c) => c.metadataId === 'custom-chip'); + const chipProgramFiles = new Set(); + for (const chip of customChips) { + const pf = String((chip.properties as any)?.programFile ?? '').trim(); + if (pf) chipProgramFiles.add(pf); } - // ── End chip-program path ─────────────────────────────────────────── + + if (customChips.length > 0) { + const boardFiles = activeBoard?.activeFileGroupId + ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) + : files; + await prepareCustomChips(customChips, boardFiles); + } + // ── End custom-chip preparation ───────────────────────────────────── const kind = activeBoard?.boardKind; @@ -361,10 +411,15 @@ export const EditorToolbar = ({ const groupFiles = activeBoard?.activeFileGroupId ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) : files; - const sketchFiles = (groupFiles.length > 0 ? groupFiles : files).map((f) => ({ - name: f.name, - content: f.content, - })); + const sketchFiles = (groupFiles.length > 0 ? groupFiles : files) + // Keep chip-program files (a chip's programFile, or .s/.asm/.hex/.bin) + // out of the arduino-cli build — they're compiled to ROM above, not + // Arduino sources, and avr-gcc chokes on e.g. SDCC's __at(). + .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) + .map((f) => ({ + name: f.name, + content: f.content, + })); // Stream live cmake + ninja output into the compilation console as // it arrives, instead of waiting for the whole build to finish. @@ -767,6 +822,24 @@ export const EditorToolbar = ({ message: `Compiling all ${boardsList.length} board${boardsList.length === 1 ? '' : 's'}...`, }); + // Make every custom-chip live (WASM + ROM) before compiling the boards, + // mirroring the single-board Compile path, and collect their program file + // names so they stay out of the arduino-cli builds below. + const allCustomChips = useSimulatorStore + .getState() + .components.filter((c) => c.metadataId === 'custom-chip'); + const chipProgramFiles = new Set(); + for (const chip of allCustomChips) { + const pf = String((chip.properties as any)?.programFile ?? '').trim(); + if (pf) chipProgramFiles.add(pf); + } + if (allCustomChips.length > 0) { + const everyFile = boardsList.flatMap((b) => + useEditorStore.getState().getGroupFiles(b.activeFileGroupId), + ); + await prepareCustomChips(allCustomChips, everyFile); + } + let ok = 0; let failed = 0; @@ -798,7 +871,9 @@ export const EditorToolbar = ({ try { const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId); - const sketchFiles = groupFiles.map((f) => ({ name: f.name, content: f.content })); + const sketchFiles = groupFiles + .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) + .map((f) => ({ name: f.name, content: f.content })); // Stream live cmake + ninja output per-board (Compile-All flow). let lastStreamedLen = 0; diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index e779b875..a7db3db3 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -74,19 +74,16 @@ void main(void) { } `; -const chaserZ80CSketch = `// Z80 LED chaser — C source compiled by SDCC. +const chaserZ80CSketch = `// Z80 LED chaser — the program is written in C (chaser.c) and compiled to +// the Z80 by SDCC on the backend. // -// The companion file is chaser.c. With sdcc installed on the backend, -// clicking Compile shells out to: -// sdcc -mz80 --code-loc 0x100 --data-loc 0x8000 chaser.c -// and the resulting Intel HEX is loaded into the z80-cpu chip's ROM. +// Just click Run. Velxio does the rest automatically: +// 1. compiles the z80-cpu chip's C source to WASM, +// 2. compiles chaser.c to a Z80 ROM (sdcc -mz80) and loads it into the chip, +// 3. compiles this (empty) Arduino sketch and starts the simulation. +// A single LED then walks back and forth across the 8 outputs. // -// Steps: -// 1. Click chaser.c in the file explorer. -// 2. Click Compile. If SDCC isn't installed yet the toolbar will -// say so — install with \`apt-get install sdcc\` (Linux) or -// \`winget install SDCC.sdcc\` (Windows), then restart the backend. -// 3. Click Run. A single LED bounces back and forth across the 8 outputs. +// Want to change the animation? Edit chaser.c and hit Run again. void setup() {} void loop() {} @@ -128,12 +125,12 @@ 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. // -// Steps: -// 1. Open larson.s and click Compile. -// 2. Click Run. A bit will walk across the 8 LEDs. +// 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). +// (higher number = slower), then hit Run again. void setup() {} void loop() {}