feat(custom-chip): newly-added programmable chip auto-gets an editable program; chaser-c goes board-less

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 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-03 22:52:11 +02:00
parent 5a23e89eb5
commit 780b80778c
4 changed files with 184 additions and 65 deletions

View File

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

View File

@ -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<FileExplorerProps> = ({ 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<string, unknown>)?.programFile ?? '').trim() !== '',
(c) => c.metadataId === 'custom-chip' && isProgrammableChip(c.properties as Record<string, unknown>),
);
// 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<string, unknown>).programFile ?? '').trim();
if (!pf) continue;
const seed = String((chip.properties as Record<string, unknown>).programSource ?? '');
ed.createFileGroup(gid, [{ name: pf, content: seed }]);
const props = chip.properties as Record<string, unknown>;
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]);

View File

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

View File

@ -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<string, unknown> | 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 */
}
}
`;