From 780b80778c29b696f9b31e6934e12e59a182220f Mon Sep 17 00:00:00 2001 From: David Montero Date: Wed, 3 Jun 2026 22:52:11 +0200 Subject: [PATCH] feat(custom-chip): newly-added programmable chip auto-gets an editable program; chaser-c goes board-less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from live testing feedback: 1. Adding a programmable chip (Z80/8080) from the gallery created NO program group — only the chip(s) from the example had one. Root cause: 'programmable' was detected by a non-empty programFile, but a fresh chip's programFile is empty until the user writes one. Now detection uses the canonical signal — chip.json's programTargets — via isProgrammableChip(). When such a chip lands with no program yet, the file explorer seeds an editable program.c (DEFAULT_CHIP_PROGRAM_C, a working walking-LED skeleton) into its own group and stamps programFile/programTarget onto the component so Compile/Run can build it. Behaviour/driver and predefined chips (no programTargets) still get no group — edited in the chip designer. 2. z80-led-chaser-c now runs board-less on a regulated power supply (no Arduino, mirroring z80-larson-no-board) — the Arduino only ever supplied 5V and added confusion. chaser.c stays the chip's editable program in its own section. - romCompileService: isProgrammableChip(), DEFAULT_CHIP_PROGRAM_FILE/_C. - FileExplorer: detect by programTargets; auto-seed program.c + persist programFile/programTarget for fresh chips. - examples-retro-intel: chaser-c -> board-less (psu + 8 resistors + 8 LEDs), drop the now-unused Arduino sketch const; fix a stale sdcc --code-loc comment. - Tests: board+chip case moved to z80-larson-scanner (still board-based); isProgrammableChip unit tests. Co-Authored-By: Claude Opus 4.8 --- .../load-example-transitions.test.ts | 34 ++++++- .../src/components/editor/FileExplorer.tsx | 51 +++++++--- frontend/src/data/examples-retro-intel.ts | 99 ++++++++++--------- frontend/src/services/romCompileService.ts | 65 ++++++++++++ 4 files changed, 184 insertions(+), 65 deletions(-) diff --git a/frontend/src/__tests__/load-example-transitions.test.ts b/frontend/src/__tests__/load-example-transitions.test.ts index 2ba5e422..3de157b9 100644 --- a/frontend/src/__tests__/load-example-transitions.test.ts +++ b/frontend/src/__tests__/load-example-transitions.test.ts @@ -17,6 +17,7 @@ import { useSimulatorStore } from '../store/useSimulatorStore'; import { useElectricalStore } from '../store/useElectricalStore'; import { loadExample } from '../utils/loadExample'; import { exampleProjects } from '../data/examples'; +import { isProgrammableChip } from '../services/romCompileService'; function resetStores() { // Clear all boards completely (also clears the file groups they own). @@ -108,27 +109,27 @@ describe('loadExample — programmable-chip program lives in its own group', () }); it('board + chip example keeps the chip program OUT of the board sketch group', async () => { - await loadExample(findExample('z80-led-chaser-c')); + await loadExample(findExample('z80-larson-scanner')); const ed = useEditorStore.getState(); const sim = useSimulatorStore.getState(); - // Board group shows only the sketch — chaser.c is NOT a sibling tab. + // Board group shows only the sketch — larson.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, 'chaser.c must not pollute the board group').not.toContain('chaser.c'); + expect(boardFiles, 'larson.s must not pollute the board group').not.toContain('larson.s'); // The chip program lives in its own group instead. const chipGroupId = 'group-chip-z80cpu'; - expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('chaser.c'); + expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('larson.s'); // With a board present the board sketch stays the active group. expect(ed.activeGroupId).toBe(board.activeFileGroupId); }); it('chip groups from a previous example do not leak into the next', async () => { - await loadExample(findExample('z80-led-chaser-c')); + await loadExample(findExample('z80-larson-scanner')); expect(useEditorStore.getState().fileGroups['group-chip-z80cpu']).toBeDefined(); // A plain board example with no custom chip must clear the stale chip group. @@ -139,3 +140,26 @@ describe('loadExample — programmable-chip program lives in its own group', () ).toBeUndefined(); }); }); + +describe('isProgrammableChip — detects ROM-loading CPUs by programTargets', () => { + it('true when chip.json declares programTargets, even with no programFile yet', () => { + // A chip freshly dropped from the gallery: programFile empty, but its + // chip.json marks it a CPU. It must still be treated as programmable so a + // program file gets created for it. + expect( + isProgrammableChip({ chipJson: JSON.stringify({ programTargets: ['z80'] }), programFile: '' }), + ).toBe(true); + }); + + it('true when a programFile is already set', () => { + expect(isProgrammableChip({ chipJson: '{}', programFile: 'larson.s' })).toBe(true); + }); + + it('false for a behaviour chip (no programTargets, no programFile)', () => { + expect( + isProgrammableChip({ chipJson: JSON.stringify({ name: 'Servo driver' }), programFile: '' }), + ).toBe(false); + expect(isProgrammableChip({})).toBe(false); + expect(isProgrammableChip(null)).toBe(false); + }); +}); diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 0f356794..823b40ea 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -2,6 +2,12 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { + isProgrammableChip, + targetForChip, + DEFAULT_CHIP_PROGRAM_FILE, + DEFAULT_CHIP_PROGRAM_C, +} from '../../services/romCompileService'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; @@ -281,28 +287,45 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId); const components = useSimulatorStore((s) => s.components); - // Programmable custom-chips (those with a `programFile`) own a program the - // user can edit — a ROM source / C — shown as its own section below the - // boards. Behaviour/driver chips and predefined chips carry no programFile - // and don't appear here (they're edited in the chip designer). + // Programmable custom-chips (CPU emulators whose chip.json declares + // programTargets) own a program the user can edit — a ROM source / C — + // shown as its own section below the boards. Behaviour/driver chips and + // predefined chips declare no programTargets and don't appear here (they're + // edited in the chip designer). const programmableChips = components.filter( - (c) => - c.metadataId === 'custom-chip' && - String((c.properties as Record)?.programFile ?? '').trim() !== '', + (c) => c.metadataId === 'custom-chip' && isProgrammableChip(c.properties as Record), ); - // Ensure each programmable chip has its editor group. loadExample seeds these - // from the example's files; this is the safety net for chips dropped onto the - // canvas (or older projects) — create an empty program file to edit. + // Ensure each programmable chip has an editable program AND its editor group. + // loadExample seeds groups from an example's files; THIS is the path for a + // chip dropped fresh from the gallery (and older projects): a fresh chip has + // no program yet, so seed a default program.c the user can edit and persist + // programFile/programTarget onto the component so Compile/Run can build it. useEffect(() => { const ed = useEditorStore.getState(); + const updateComponent = useSimulatorStore.getState().updateComponent; for (const chip of programmableChips) { const gid = chipFileGroupId(chip.id); if (ed.fileGroups[gid]) continue; - const pf = String((chip.properties as Record).programFile ?? '').trim(); - if (!pf) continue; - const seed = String((chip.properties as Record).programSource ?? ''); - ed.createFileGroup(gid, [{ name: pf, content: seed }]); + const props = chip.properties as Record; + const existing = String(props.programFile ?? '').trim(); + if (existing) { + // Chip already names its program (e.g. an example) — seed from its + // saved source if any, else empty (loadExample usually filled it). + ed.createFileGroup(gid, [ + { name: existing, content: String(props.programSource ?? '') }, + ]); + } else { + // Fresh chip from the gallery — give it a starter program.c and + // remember its target CPU for the ROM compiler. + const target = targetForChip(String(props.chipJson ?? '{}')); + updateComponent(chip.id, { + properties: { ...props, programFile: DEFAULT_CHIP_PROGRAM_FILE, programTarget: target }, + }); + ed.createFileGroup(gid, [ + { name: DEFAULT_CHIP_PROGRAM_FILE, content: DEFAULT_CHIP_PROGRAM_C }, + ]); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [components]); diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index 5b4c5350..68c5e927 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -33,7 +33,7 @@ const chaserZ80C = `/* LED chaser written in C, compiled to Z80 by SDCC. * * Demonstrates that you can program the Z80 chip in C (not just asm). * The backend runs: - * sdcc -mz80 --code-loc 0x100 --data-loc 0x8000 program.c + * sdcc -mz80 --data-loc 0x8000 program.c * and feeds the resulting Intel HEX into the chip via vx_rom_read. * * The MMIO addresses (0xC000 LED, 0xC003 BTN, 0xC001 UART_DATA, @@ -77,21 +77,6 @@ void main(void) { } `; -const chaserZ80CSketch = `// Z80 LED chaser — the program is written in C (chaser.c) and compiled to -// the Z80 by SDCC on the backend. -// -// 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. -// -// Want to change the animation? Edit chaser.c and hit Run again. - -void setup() {} -void loop() {} -`; - const larsonZ80Asm = `; Larson Scanner / Knight Rider in Z80 assembly. ; ; A single LED walks left across 8 LEDs forever. Uses JR/DJNZ/RLCA -- @@ -554,28 +539,37 @@ export const retroIntelExamples: ExampleProject[] = [ ], }, - // ── Z80 LED chaser in C (SDCC) ───────────────────────────────────── + // ── Z80 LED chaser in C (SDCC) — NO board, regulated supply ───────── + // The Z80 program is written in C (chaser.c) and compiled by SDCC. No + // Arduino: the chip is powered by a regulated bench supply, same as + // z80-larson-no-board. Board-less (boardFilter: 'digital'); chaser.c is + // the chip's editable program (its own section in the file explorer). { id: 'z80-led-chaser-c', - title: 'Z80 LED Chaser (C via SDCC)', + title: 'Z80 LED Chaser in C (no board)', description: - 'Same z80-cpu chip, but the program is written in C and compiled by SDCC at compile time. ' + - 'A single LED walks back and forth Larson-style. Requires sdcc installed on the backend.', + 'A programmable Z80 chip walks a single LED back and forth, Larson-style — but the ' + + 'program is written in C (chaser.c) and compiled to the Z80 by SDCC. No Arduino: the ' + + 'chip runs standalone, powered by a regulated supply. Click Run. Requires sdcc on the backend.', category: 'circuits', difficulty: 'advanced', - boardType: 'arduino-uno', - tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'c', 'sdcc', 'wasm', 'custom-chip', 'programmable'], - code: chaserZ80CSketch, - files: [ - { name: 'sketch.ino', content: chaserZ80CSketch }, - { name: 'chaser.c', content: chaserZ80C }, - ], + boardFilter: 'digital', + tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'c', 'sdcc', 'no-board', 'power-supply', 'wasm', 'custom-chip', 'spice', 'programmable'], + code: chaserZ80C, + files: [{ name: 'chaser.c', content: chaserZ80C }], 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, @@ -586,39 +580,52 @@ export const retroIntelExamples: ExampleProject[] = [ 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, + x: 900, + y: 110 + i * 50, properties: { color: 'red' }, })), ], 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: `wire-led-${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: `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', })), - { - id: 'wire-z80c-vcc', - start: { componentId: 'z80cpu', pinName: 'VCC' }, - end: { componentId: 'arduino-uno', pinName: '5V' }, - color: '#e74c3c', - }, - { - id: 'wire-z80c-gnd', - start: { componentId: 'z80cpu', pinName: 'GND' }, - end: { componentId: 'arduino-uno', pinName: 'GND' }, - color: '#000000', - }, ], }, diff --git a/frontend/src/services/romCompileService.ts b/frontend/src/services/romCompileService.ts index d17d9088..3379a887 100644 --- a/frontend/src/services/romCompileService.ts +++ b/frontend/src/services/romCompileService.ts @@ -80,3 +80,68 @@ export function targetForChip(chipJsonStr: string): RomTarget { } catch { /* ignore */ } return '8080'; } + +/** + * A custom chip is "programmable" — it runs a user program / ROM, like a CPU + * emulator — when its chip.json declares `programTargets`, or it already + * references a program file. Behaviour / driver chips (a servo driver, a + * sensor) declare no programTargets and are edited only in the chip designer. + * + * This (not `programFile`) is the canonical predicate: a chip dropped fresh + * from the gallery has an empty programFile until we seed one, but its + * chip.json already says it's a CPU. + */ +export function isProgrammableChip( + props: Record | null | undefined, +): boolean { + if (!props) return false; + if (String(props.programFile ?? '').trim()) return true; + try { + const obj = JSON.parse(String(props.chipJson ?? '{}')); + return Array.isArray(obj.programTargets) && obj.programTargets.length > 0; + } catch { + return false; + } +} + +/** Default editable program file name for a freshly-added programmable chip. + * We seed C — SDCC compiles it to the chip's CPU (z80 / 8080 / ...). */ +export const DEFAULT_CHIP_PROGRAM_FILE = 'program.c'; + +/** + * Starter C program seeded into a newly-added programmable chip's editor + * group, so the chip has an editable program from the moment it lands on the + * canvas. Walks a single LED across the 8 memory-mapped outputs — it compiles + * and does something visible on Run. Mirrors the working chaser.c idiom + * (volatile MMIO pointer + nop-based delay; SDCC treats plain `char` as + * unsigned on these CPUs, so the pattern uses an explicit unsigned byte). + */ +export const DEFAULT_CHIP_PROGRAM_C = `/* Program for the programmable CPU chip — compiled by SDCC and loaded as the + * chip's ROM. Memory-mapped I/O matches the z80-cpu / i8080-cpu map: + * + * 0xC000 LED_OUT write: bit i drives output pin LEDi + * 0xC003 BTN_IN read: bit i reads input pin BTNi + * + * Edit this and click Run. (Rename to .s to write assembly instead.) + */ +#define LED_OUT (*(volatile unsigned char *)0xC000) +#define BTN_IN (*(volatile unsigned char *)0xC003) + +static void delay(unsigned int loops) { + while (loops--) { + __asm + nop + __endasm; + } +} + +void main(void) { + unsigned char bit = 0x01; + while (1) { + LED_OUT = bit; /* light one LED */ + delay(5000); + bit <<= 1; /* walk it left */ + if (bit == 0) bit = 0x01; /* wrap around */ + } +} +`;