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:
parent
5a23e89eb5
commit
780b80778c
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue