This commit is contained in:
ApfelTeeSaft
2026-07-19 18:05:04 +02:00
parent e06256d9ab
commit cafc87e8ad
125 changed files with 51873 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{cpp,h,hpp}]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
+141
View File
@@ -0,0 +1,141 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Superseded runs of the same PR/branch are cancelled to save runner time.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
EMSDK_VERSION: 5.0.7 # keep in sync with scripts/build-wasm.sh
jobs:
cpp-tests:
name: C++ engine (native tests)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S wasm -B wasm/build-native
- name: Build
run: cmake --build wasm/build-native --parallel
- name: Test
run: ctest --test-dir wasm/build-native --output-on-failure
wasm:
name: WASM engine build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: emscripten-core/setup-emsdk@v15
with:
version: 5.0.7
actions-cache-folder: emsdk-cache
- name: Build engine to WebAssembly
run: ./scripts/build-wasm.sh
- name: Conformance vectors against the wasm build (Node)
run: |
emcmake cmake -S wasm -B wasm/build-wasm -DCMAKE_BUILD_TYPE=Release -DWEBMETAL_BUILD_NODE_TEST=ON
cmake --build wasm/build-wasm --parallel
node scripts/run-conformance-node.mjs
- uses: actions/upload-artifact@v4
with:
name: webmetal-wasm
path: frontend/public/wasm/
if-no-files-found: error
frontend:
name: Frontend (lint, typecheck, test, build)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
- name: Typecheck
run: npm run typecheck
- name: Test (unit + conformance + bundled examples)
run: npm test
- name: Build (without wasm - must succeed standalone)
run: npm run build
integration:
name: Integration (frontend + wasm together)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: wasm
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: actions/download-artifact@v4
with:
name: webmetal-wasm
path: frontend/public/wasm/
- name: Install dependencies
run: npm ci
- name: Build with the engine present
run: npm run build
- name: Verify the engine ships in dist/
run: |
test -f dist/wasm/webmetal-engine.mjs
test -f dist/wasm/webmetal-engine.wasm
- name: Verify dist/ is subdirectory-safe (relative asset paths)
run: |
grep -q 'src="\./assets/' dist/index.html
grep -q 'href="\./' dist/index.html
if grep -q '"/assets/' dist/index.html; then
echo 'index.html references absolute /assets/ paths' >&2
exit 1
fi
- name: Upload ready-to-deploy site
uses: actions/upload-artifact@v4
with:
name: webmetal-dist
path: frontend/dist/
if-no-files-found: error
retention-days: 30
# Single aggregate check for branch protection: require only this job.
ci-ok:
name: CI passed
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [cpp-tests, wasm, frontend, integration]
if: always()
steps:
- name: Check that every job succeeded
run: |
results='${{ join(needs.*.result, ' ') }}'
echo "job results: $results"
for r in $results; do
if [ "$r" != "success" ]; then
echo "a required job did not succeed" >&2
exit 1
fi
done
+21
View File
@@ -0,0 +1,21 @@
# Dependencies and build output
node_modules/
dist/
*.local
# C++ / CMake build directories
wasm/build/
wasm/build-*/
# Local Emscripten SDK (scripts/build-wasm.sh --install)
.emsdk/
# Generated wasm artifacts consumed by the frontend (scripts/build-wasm.sh)
frontend/public/wasm/
# Editor and OS noise
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ApfelTeeSaft and WebMetal contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+105
View File
@@ -0,0 +1,105 @@
# WebMetal
**WebMetal** is a browser-only, backend-free web app for designing custom CPU
architectures visually - a Scratch-3-inspired block/flow editor where you wire
up registers, ALUs, memory, and control logic; define your own instruction set;
write assembly for it; and run or step through programs in a built-in
emulator/debugger. Everything exports to (and re-imports from) a single JSON
file, with the visual layout preserved.
## What WebMetal does
1. **Design a CPU visually** - registers, register files, program counter,
flags, ALU, memory, instruction decoder, control unit, and comment notes as
draggable, connectable, documentable blocks, with live structural
validation (unwired inputs, bus-width mismatches, missing components).
2. **Define its instruction set** - mnemonics, operands, bit-level encodings,
flags affected, and execution behavior written as micro-ops, all checked
against the designed machine.
3. **Write assembly for it** - a CodeMirror editor whose highlighting and
diagnostics are generated from _your_ ISA, with labels, `.org`/`.word`/
`.byte` directives, and inline assemble-on-type errors.
4. **Run and debug** - a C++->WebAssembly emulation core with step/run/pause,
speed presets, breakpoints in the source gutter, register/flag/memory
views (editable while stopped), and a trace log.
5. **Export/import everything** - one versioned `.webmetal.json` document
(architecture, ISA, programs, layout, docs) with format migrations, plus
localStorage autosave.
6. **Learn from bundled examples** - three original CPUs in difficulty order
(EDU-CORE -> TOY-CPU -> RETRO-8).
## Tech stack
| Layer | Choice |
| ------------------ | ------------------------------------------------------------ |
| Frontend | React + TypeScript + Vite (static build, no backend) |
| Graph editor | React Flow (`@xyflow/react`) |
| Assembly editor | CodeMirror 6 |
| Simulation core | C++20 -> WebAssembly (Emscripten + embind, CMake) |
| State / validation | Zustand / Zod |
| Tests | Vitest (TS), doctest (C++), shared JSON conformance vectors |
| CI | GitHub Actions (single required `CI passed` aggregate check) |
The execution model is two-layered: the visual graph compiles to a flat
machine model, instruction behaviors are micro-op sequences, and the same
micro-op semantics are implemented twice - a TypeScript reference executor
and the C++/wasm engine - pinned to each other by shared conformance vectors
(`conformance/`) that run in Vitest, native CTest, and Node-on-wasm.
## Repository layout
```txt
frontend/ Vite + React + TypeScript app (UI, editors, assembler, model)
wasm/ C++ emulator core, built to WebAssembly (CMake + Emscripten)
examples/ Bundled example CPU projects (.webmetal.json)
conformance/ Micro-op conformance vectors shared by all executors
scripts/ Build helpers (wasm build, Node conformance runner)
.github/ CI workflow
```
## Building
Requires [Node.js](https://nodejs.org/) > 20. From a clean checkout:
```sh
cd frontend
npm ci
npm run dev # development server with hot reload
```
The app runs without the engine (design/ISA/assembler work; the Run view
reports the engine as unavailable). To build the emulator core too:
```sh
scripts/build-wasm.sh --install # first time: installs pinned emsdk into .emsdk/
scripts/build-wasm.sh # builds into frontend/public/wasm/
```
Production build (static files in `frontend/dist/`):
```sh
cd frontend
npm run build
npm run preview # serve dist/ locally to inspect it
```
Checks (all also run in CI):
```sh
npm run lint && npm run format:check && npm run typecheck && npm test
```
Native C++ unit tests (no Emscripten required):
```sh
cmake -S wasm -B wasm/build-native && cmake --build wasm/build-native --parallel
ctest --test-dir wasm/build-native --output-on-failure
```
## Deployment
`dist/` is a plain static site - any web server or object store can host it.
## License
[MIT](LICENSE).
+556
View File
@@ -0,0 +1,556 @@
{
"description": "WebMetal micro-op conformance vectors. Every engine implementation (TypeScript reference, C++/WASM) must pass all vectors. 'expect' blocks are partial: only listed values are compared. Semantics: docs/execution-model.md.",
"defaultModel": {
"modelVersion": 1,
"banks": [{ "name": "R", "width": 8, "count": 4 }],
"memories": [{ "name": "MAIN", "size": 256, "width": 8 }],
"flags": ["Z", "N", "C"],
"pc": { "width": 16 },
"programMemory": "MAIN"
},
"vectors": [
{
"name": "move const to register, masked to bank width",
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "const", "value": 511 }
}
]
}
],
"expect": { "banks": { "R": [255, 0, 0, 0] } }
},
{
"name": "move register to register via operand-selected index",
"setup": { "banks": { "R": [0, 42] } },
"run": [
{
"operands": { "rd": 3, "rs": 1 },
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "operand", "field": "rd" } },
"src": { "kind": "reg", "bank": "R", "index": { "kind": "operand", "field": "rs" } }
}
]
}
],
"expect": { "banks": { "R": [0, 42, 0, 42] } }
},
{
"name": "operand value through temp into register",
"run": [
{
"operands": { "imm": 77 },
"microOps": [
{
"op": "move",
"dst": { "kind": "temp", "index": 2 },
"src": { "kind": "operand", "field": "imm" }
},
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"src": { "kind": "temp", "index": 2 }
}
]
}
],
"expect": { "banks": { "R": [0, 77, 0, 0] } }
},
{
"name": "add without setFlags leaves flags untouched",
"setup": { "banks": { "R": [5, 7] }, "flags": { "Z": true, "C": true } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": false
}
]
}
],
"expect": { "banks": { "R": [12, 7] }, "flags": { "Z": true, "C": true } }
},
{
"name": "add overflow sets C and Z (0xFF + 1 = 0x00)",
"setup": { "banks": { "R": [255, 1] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [0, 1] }, "flags": { "Z": true, "N": false, "C": true } }
},
{
"name": "add sets N from the result msb (0x40 + 0x40 = 0x80)",
"setup": { "banks": { "R": [64, 64] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [128, 64] }, "flags": { "Z": false, "N": true, "C": false } }
},
{
"name": "sub with no borrow sets C (5 - 3, ARM/6502 convention)",
"setup": { "banks": { "R": [5, 3] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [2, 3] }, "flags": { "Z": false, "N": false, "C": true } }
},
{
"name": "sub with borrow clears C and wraps (3 - 5 = 0xFE)",
"setup": { "banks": { "R": [3, 5] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [254, 5] }, "flags": { "Z": false, "N": true, "C": false } }
},
{
"name": "compare pattern: sub into a temp only affects flags",
"setup": { "banks": { "R": [9, 9] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": { "kind": "temp", "index": 0 },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [9, 9] }, "flags": { "Z": true, "N": false, "C": true } }
},
{
"name": "and clears C and can set Z",
"setup": { "banks": { "R": [240, 15] }, "flags": { "C": true } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "and",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [0, 15] }, "flags": { "Z": true, "N": false, "C": false } }
},
{
"name": "or and xor produce expected bit patterns",
"setup": { "banks": { "R": [12, 10, 12, 10] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "or",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"setFlags": false
},
{
"op": "alu",
"fn": "xor",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 2 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 2 } },
"b": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 3 } },
"setFlags": false
}
]
}
],
"expect": { "banks": { "R": [14, 10, 6, 10] } }
},
{
"name": "not is unary and masks to width",
"setup": { "banks": { "R": [15] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "not",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [240] }, "flags": { "Z": false, "N": true, "C": false } }
},
{
"name": "shl shifts one bit; C receives the old msb",
"setup": { "banks": { "R": [129] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "shl",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [2] }, "flags": { "Z": false, "N": false, "C": true } }
},
{
"name": "shr shifts one bit; C receives the old bit 0",
"setup": { "banks": { "R": [5] } },
"run": [
{
"microOps": [
{
"op": "alu",
"fn": "shr",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [2] }, "flags": { "Z": false, "N": false, "C": true } }
},
{
"name": "alu inputs are truncated to the op width before computing",
"run": [
{
"operands": { "big": 260 },
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"a": { "kind": "operand", "field": "big" },
"b": { "kind": "const", "value": 0 },
"setFlags": true
}
]
}
],
"expect": { "banks": { "R": [4] }, "flags": { "Z": false, "C": false } }
},
{
"name": "load reads a memory word",
"setup": { "memories": { "MAIN": { "16": 99 } } },
"run": [
{
"microOps": [
{
"op": "load",
"memory": "MAIN",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"addr": { "kind": "const", "value": 16 }
}
]
}
],
"expect": { "banks": { "R": [99] } }
},
{
"name": "store writes a memory word, masked to memory width",
"run": [
{
"operands": { "addr": 32 },
"microOps": [
{
"op": "store",
"memory": "MAIN",
"addr": { "kind": "operand", "field": "addr" },
"src": { "kind": "const", "value": 300 }
}
]
}
],
"expect": { "memories": { "MAIN": { "32": 44 } } }
},
{
"name": "setFlag stores (value != 0)",
"run": [
{
"microOps": [
{ "op": "setFlag", "name": "C", "src": { "kind": "const", "value": 2 } },
{ "op": "setFlag", "name": "Z", "src": { "kind": "const", "value": 0 } }
]
}
],
"expect": { "flags": { "C": true, "Z": false } }
},
{
"name": "flag reads back as 0 or 1 through a move",
"setup": { "flags": { "N": true } },
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "flag", "name": "N" }
},
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"src": { "kind": "flag", "name": "Z" }
}
]
}
],
"expect": { "banks": { "R": [1, 0] } }
},
{
"name": "jump sets pc, masked to pc width",
"run": [
{
"microOps": [
{ "op": "jump", "target": { "kind": "const", "value": 65540 } }
]
}
],
"expect": { "pc": 4 }
},
{
"name": "branch taken when flag matches ifSet",
"setup": { "flags": { "Z": true }, "pc": 10 },
"run": [
{
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": true,
"target": { "kind": "const", "value": 200 }
}
]
}
],
"expect": { "pc": 200 }
},
{
"name": "branch not taken leaves pc alone",
"setup": { "flags": { "Z": false }, "pc": 10 },
"run": [
{
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": true,
"target": { "kind": "const", "value": 200 }
}
]
}
],
"expect": { "pc": 10 }
},
{
"name": "halt stops the sequence and freezes the machine",
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "const", "value": 1 }
},
{ "op": "halt" },
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "const", "value": 9 }
}
]
},
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 1 } },
"src": { "kind": "const", "value": 9 }
}
]
}
],
"expect": { "banks": { "R": [1, 0] }, "halted": true }
},
{
"name": "temps are cleared between sequences",
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "temp", "index": 0 },
"src": { "kind": "const", "value": 123 }
}
]
},
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "temp", "index": 0 }
}
]
}
],
"expect": { "banks": { "R": [0] } }
},
{
"name": "pc can be read as a value source",
"setup": { "pc": 513 },
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "pc" }
}
]
}
],
"expect": { "banks": { "R": [1] } }
},
{
"name": "error: unknown register bank",
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "GHOST", "index": { "kind": "literal", "value": 0 } },
"src": { "kind": "const", "value": 1 }
}
]
}
],
"expectError": "unknown register bank"
},
{
"name": "error: register index out of range",
"run": [
{
"operands": { "rd": 12 },
"microOps": [
{
"op": "move",
"dst": { "kind": "reg", "bank": "R", "index": { "kind": "operand", "field": "rd" } },
"src": { "kind": "const", "value": 1 }
}
]
}
],
"expectError": "out of range"
},
{
"name": "error: memory address out of range",
"run": [
{
"microOps": [
{
"op": "load",
"memory": "MAIN",
"dst": { "kind": "temp", "index": 0 },
"addr": { "kind": "const", "value": 256 }
}
]
}
],
"expectError": "out of range"
},
{
"name": "error: unknown operand field",
"run": [
{
"microOps": [
{
"op": "move",
"dst": { "kind": "temp", "index": 0 },
"src": { "kind": "operand", "field": "missing" }
}
]
}
],
"expectError": "unknown operand field"
}
]
}
+525
View File
@@ -0,0 +1,525 @@
{
"formatVersion": 3,
"metadata": {
"name": "EDU-CORE",
"author": "WebMetal examples",
"description": "A minimal educational CPU: one accumulator, six instructions, and a single 256-word memory. The smallest design that can run a real loop.",
"createdAt": "2026-07-17T00:00:00.000Z",
"generator": {
"app": "WebMetal",
"version": "0.12.0"
}
},
"graph": {
"nodes": [
{
"id": "note-title",
"type": "comment",
"position": {
"x": 40,
"y": -180
},
"data": {
"name": "NOTE1",
"doc": "",
"params": {
"text": "EDU-CORE - a minimal 8-bit accumulator machine.\n\nFollow a wire from PC1 to MEM to DEC1 to see how an instruction is fetched and decoded. Open the Instruction Set tab to see how each of the six instructions is written as micro-ops, then press Run to watch the demo program count down."
}
}
},
{
"id": "note-execute",
"type": "comment",
"position": {
"x": 940,
"y": 20
},
"data": {
"name": "NOTE2",
"doc": "",
"params": {
"text": "Execute path: the ALU combines ACC with the decoded immediate. Results latch back into ACC; Z and N latch into FLAGS1, where JNZ can test them."
}
}
},
{
"id": "acc",
"type": "register",
"position": {
"x": 40,
"y": 40
},
"data": {
"name": "ACC",
"doc": "The accumulator - the machines only general-purpose register. Every ALU result lands here, and STA writes it to memory.",
"params": {
"width": 8
}
}
},
{
"id": "alu1",
"type": "alu",
"position": {
"x": 340,
"y": 40
},
"data": {
"name": "ALU1",
"doc": "Performs ADD and SUB on the accumulator and the instructions immediate operand, and reports Zero/Negative to the flags block.",
"params": {
"width": 8
}
}
},
{
"id": "flags1",
"type": "flags",
"position": {
"x": 640,
"y": 40
},
"data": {
"name": "FLAGS1",
"doc": "Two status flags: Z (last result was zero) and N (top bit of the last result). JNZ tests Z.",
"params": {
"flags": "Z,N"
}
}
},
{
"id": "pc1",
"type": "pc",
"position": {
"x": 40,
"y": 320
},
"data": {
"name": "PC1",
"doc": "Holds the address of the next instruction word. 8 bits wide because MEM has 256 words (2⁸). The control unit increments it past each instruction; JNZ can load it with a branch target instead.",
"params": {
"width": 8
}
}
},
{
"id": "mem",
"type": "memory",
"position": {
"x": 340,
"y": 320
},
"data": {
"name": "MEM",
"doc": "Unified program + data memory: 256 words of 8 bits. Instructions are fetched from here, and STA writes the accumulator back into it.",
"params": {
"size": 256,
"width": 8
}
}
},
{
"id": "dec1",
"type": "decoder",
"position": {
"x": 640,
"y": 320
},
"data": {
"name": "DEC1",
"doc": "Splits a fetched instruction into its opcode (word 0) and its immediate/address operand (word 1).",
"params": {}
}
},
{
"id": "ctl1",
"type": "control",
"position": {
"x": 940,
"y": 320
},
"data": {
"name": "CTL1",
"doc": "Sequences fetch -> decode -> execute: asserts the load, increment, latch, and write signals that make the datapath move.",
"params": {}
}
}
],
"edges": [
{
"id": "pc1.out->mem.addr",
"source": "pc1",
"sourceHandle": "out",
"target": "mem",
"targetHandle": "addr",
"kind": "data"
},
{
"id": "mem.dout->dec1.instr",
"source": "mem",
"sourceHandle": "dout",
"target": "dec1",
"targetHandle": "instr",
"kind": "data"
},
{
"id": "acc.out->alu1.a",
"source": "acc",
"sourceHandle": "out",
"target": "alu1",
"targetHandle": "a",
"kind": "data"
},
{
"id": "dec1.imm->alu1.b",
"source": "dec1",
"sourceHandle": "imm",
"target": "alu1",
"targetHandle": "b",
"kind": "data"
},
{
"id": "alu1.result->acc.in",
"source": "alu1",
"sourceHandle": "result",
"target": "acc",
"targetHandle": "in",
"kind": "data"
},
{
"id": "alu1.flags->flags1.in",
"source": "alu1",
"sourceHandle": "flags",
"target": "flags1",
"targetHandle": "in",
"kind": "data"
},
{
"id": "acc.out->mem.din",
"source": "acc",
"sourceHandle": "out",
"target": "mem",
"targetHandle": "din",
"kind": "data"
},
{
"id": "dec1.imm->pc1.next",
"source": "dec1",
"sourceHandle": "imm",
"target": "pc1",
"targetHandle": "next",
"kind": "data"
},
{
"id": "flags1.out->ctl1.flags",
"source": "flags1",
"sourceHandle": "out",
"target": "ctl1",
"targetHandle": "flags",
"kind": "data"
},
{
"id": "dec1.ctl->ctl1.decoded",
"source": "dec1",
"sourceHandle": "ctl",
"target": "ctl1",
"targetHandle": "decoded",
"kind": "control"
},
{
"id": "dec1.ctl->alu1.op",
"source": "dec1",
"sourceHandle": "ctl",
"target": "alu1",
"targetHandle": "op",
"kind": "control"
},
{
"id": "ctl1.signals->acc.load",
"source": "ctl1",
"sourceHandle": "signals",
"target": "acc",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl1.signals->pc1.inc",
"source": "ctl1",
"sourceHandle": "signals",
"target": "pc1",
"targetHandle": "inc",
"kind": "control"
},
{
"id": "ctl1.signals->pc1.load",
"source": "ctl1",
"sourceHandle": "signals",
"target": "pc1",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl1.signals->flags1.latch",
"source": "ctl1",
"sourceHandle": "signals",
"target": "flags1",
"targetHandle": "latch",
"kind": "control"
},
{
"id": "ctl1.signals->mem.write",
"source": "ctl1",
"sourceHandle": "signals",
"target": "mem",
"targetHandle": "write",
"kind": "control"
},
{
"id": "ctl1.signals->mem.read",
"source": "ctl1",
"sourceHandle": "signals",
"target": "mem",
"targetHandle": "read",
"kind": "control"
}
],
"viewport": {
"x": 30,
"y": 210,
"zoom": 0.8
}
},
"isa": {
"description": "EDU-CORE: six instructions around a single accumulator. The opcode fills word 0; the operand (if any) fills word 1.",
"opcodeField": {
"offset": 0,
"width": 8
},
"instructions": [
{
"id": "edu-hlt",
"mnemonic": "HLT",
"opcode": 0,
"words": 1,
"operands": [],
"flagsAffected": [],
"doc": "Stop the machine.",
"microOps": [
{
"op": "halt"
}
]
},
{
"id": "edu-ldi",
"mnemonic": "LDI",
"opcode": 1,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Load an immediate value into the accumulator.",
"microOps": [
{
"op": "move",
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"src": {
"kind": "operand",
"field": "value"
}
}
]
},
{
"id": "edu-add",
"mnemonic": "ADD",
"opcode": 2,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [
"Z",
"N"
],
"doc": "Add an immediate to the accumulator.",
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"a": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "operand",
"field": "value"
},
"setFlags": true
}
]
},
{
"id": "edu-sub",
"mnemonic": "SUB",
"opcode": 3,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [
"Z",
"N"
],
"doc": "Subtract an immediate from the accumulator.",
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"a": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "operand",
"field": "value"
},
"setFlags": true
}
]
},
{
"id": "edu-sta",
"mnemonic": "STA",
"opcode": 4,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Store the accumulator to a memory address.",
"microOps": [
{
"op": "store",
"memory": "MEM",
"addr": {
"kind": "operand",
"field": "target"
},
"src": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
}
}
]
},
{
"id": "edu-jnz",
"mnemonic": "JNZ",
"opcode": 5,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Jump to an address when the Z flag is clear (last result was not zero).",
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": false,
"target": {
"kind": "operand",
"field": "target"
}
}
]
}
]
},
"programs": [
{
"id": "edu-demo",
"name": "countdown",
"source": "; EDU-CORE demo - count down from 3.\n; Each pass stores the counter to address 32 (0x20),\n; so you can watch MEM[0x20] change in the Run view.\n\n LDI #3 ; ACC = 3\nloop:\n STA 32 ; MEM[32] = ACC\n SUB #1 ; ACC = ACC - 1, sets Z when it reaches 0\n JNZ loop ; repeat until ACC == 0\n HLT ; done: MEM[32] ends at 1, ACC at 0\n"
}
]
}
+860
View File
@@ -0,0 +1,860 @@
{
"formatVersion": 3,
"metadata": {
"name": "RETRO-8",
"author": "WebMetal examples",
"description": "An original 8-bit accumulator machine in the spirit of late-70s home-computer CPUs: Z/N/C flags, immediate, absolute, and X-indexed addressing.",
"createdAt": "2026-07-17T00:00:00.000Z",
"generator": {
"app": "WebMetal",
"version": "0.12.0"
}
},
"graph": {
"nodes": [
{
"id": "note-title",
"type": "comment",
"position": {
"x": 40,
"y": -190
},
"data": {
"name": "NOTE1",
"doc": "",
"params": {
"text": "RETRO-8 - an original 8-bit accumulator machine with three addressing modes.\n\nImmediate (LDI #5), absolute (LDA 96), and X-indexed (STAX 96 stores to 96 + X). The X register plus INX turn straight-line code into loops over arrays - the demo fills five memory cells that way."
}
}
},
{
"id": "note-indexed",
"type": "comment",
"position": {
"x": 940,
"y": 110
},
"data": {
"name": "NOTE2",
"doc": "",
"params": {
"text": "Indexed addressing: STAX adds the X register to the instructions address operand inside the ALU before the store - the classic base + index trick."
}
}
},
{
"id": "acc",
"type": "register",
"position": {
"x": 40,
"y": 20
},
"data": {
"name": "ACC",
"doc": "The accumulator: source and destination of all arithmetic, and the value stored by STA/STAX.",
"params": {
"width": 8
}
}
},
{
"id": "x",
"type": "register",
"position": {
"x": 40,
"y": 200
},
"data": {
"name": "X",
"doc": "The index register. STAX adds it to the address operand; INX steps it through an array.",
"params": {
"width": 8
}
}
},
{
"id": "alu",
"type": "alu",
"position": {
"x": 340,
"y": 110
},
"data": {
"name": "ALU",
"doc": "Adds/subtracts the accumulator with an immediate or a memory operand, and computes indexed addresses for STAX.",
"params": {
"width": 8
}
}
},
{
"id": "flags",
"type": "flags",
"position": {
"x": 640,
"y": 110
},
"data": {
"name": "FLAGS",
"doc": "Z (zero), N (negative), C (carry / no-borrow). CMP subtracts without writing ACC, just to set these for BEQ/BNE.",
"params": {
"flags": "Z,N,C"
}
}
},
{
"id": "pc",
"type": "pc",
"position": {
"x": 40,
"y": 440
},
"data": {
"name": "PC",
"doc": "8-bit program counter addressing MEMs 256 words. Branches and JMP load it with a target address.",
"params": {
"width": 8
}
}
},
{
"id": "mem",
"type": "memory",
"position": {
"x": 340,
"y": 440
},
"data": {
"name": "MEM",
"doc": "Unified program + data memory: 256 × 8-bit words. The demo writes its output table at addresses 96-100.",
"params": {
"size": 256,
"width": 8
}
}
},
{
"id": "dec",
"type": "decoder",
"position": {
"x": 640,
"y": 440
},
"data": {
"name": "DEC",
"doc": "Splits fetched words into the opcode and the immediate/address operand.",
"params": {}
}
},
{
"id": "ctl",
"type": "control",
"position": {
"x": 940,
"y": 440
},
"data": {
"name": "CTL",
"doc": "Turns decoded instructions and flag state into register loads, memory read/write strobes, and PC updates.",
"params": {}
}
}
],
"edges": [
{
"id": "pc.out->mem.addr",
"source": "pc",
"sourceHandle": "out",
"target": "mem",
"targetHandle": "addr",
"kind": "data"
},
{
"id": "mem.dout->dec.instr",
"source": "mem",
"sourceHandle": "dout",
"target": "dec",
"targetHandle": "instr",
"kind": "data"
},
{
"id": "acc.out->alu.a",
"source": "acc",
"sourceHandle": "out",
"target": "alu",
"targetHandle": "a",
"kind": "data"
},
{
"id": "x.out->alu.a",
"source": "x",
"sourceHandle": "out",
"target": "alu",
"targetHandle": "a",
"kind": "data"
},
{
"id": "mem.dout->alu.b",
"source": "mem",
"sourceHandle": "dout",
"target": "alu",
"targetHandle": "b",
"kind": "data"
},
{
"id": "dec.imm->alu.b",
"source": "dec",
"sourceHandle": "imm",
"target": "alu",
"targetHandle": "b",
"kind": "data"
},
{
"id": "alu.result->acc.in",
"source": "alu",
"sourceHandle": "result",
"target": "acc",
"targetHandle": "in",
"kind": "data"
},
{
"id": "alu.result->x.in",
"source": "alu",
"sourceHandle": "result",
"target": "x",
"targetHandle": "in",
"kind": "data"
},
{
"id": "alu.flags->flags.in",
"source": "alu",
"sourceHandle": "flags",
"target": "flags",
"targetHandle": "in",
"kind": "data"
},
{
"id": "acc.out->mem.din",
"source": "acc",
"sourceHandle": "out",
"target": "mem",
"targetHandle": "din",
"kind": "data"
},
{
"id": "dec.imm->pc.next",
"source": "dec",
"sourceHandle": "imm",
"target": "pc",
"targetHandle": "next",
"kind": "data"
},
{
"id": "flags.out->ctl.flags",
"source": "flags",
"sourceHandle": "out",
"target": "ctl",
"targetHandle": "flags",
"kind": "data"
},
{
"id": "dec.ctl->ctl.decoded",
"source": "dec",
"sourceHandle": "ctl",
"target": "ctl",
"targetHandle": "decoded",
"kind": "control"
},
{
"id": "dec.ctl->alu.op",
"source": "dec",
"sourceHandle": "ctl",
"target": "alu",
"targetHandle": "op",
"kind": "control"
},
{
"id": "ctl.signals->acc.load",
"source": "ctl",
"sourceHandle": "signals",
"target": "acc",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl.signals->x.load",
"source": "ctl",
"sourceHandle": "signals",
"target": "x",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl.signals->pc.inc",
"source": "ctl",
"sourceHandle": "signals",
"target": "pc",
"targetHandle": "inc",
"kind": "control"
},
{
"id": "ctl.signals->pc.load",
"source": "ctl",
"sourceHandle": "signals",
"target": "pc",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl.signals->flags.latch",
"source": "ctl",
"sourceHandle": "signals",
"target": "flags",
"targetHandle": "latch",
"kind": "control"
},
{
"id": "ctl.signals->mem.write",
"source": "ctl",
"sourceHandle": "signals",
"target": "mem",
"targetHandle": "write",
"kind": "control"
},
{
"id": "ctl.signals->mem.read",
"source": "ctl",
"sourceHandle": "signals",
"target": "mem",
"targetHandle": "read",
"kind": "control"
}
],
"viewport": {
"x": 30,
"y": 215,
"zoom": 0.75
}
},
"isa": {
"description": "RETRO-8: accumulator-implicit instructions with immediate (#n), absolute (n), and X-indexed (STAX) addressing. Opcode in word 0, operand in word 1.",
"opcodeField": {
"offset": 0,
"width": 8
},
"instructions": [
{
"id": "r8-hlt",
"mnemonic": "HLT",
"opcode": 0,
"words": 1,
"operands": [],
"flagsAffected": [],
"doc": "Stop the machine.",
"microOps": [
{
"op": "halt"
}
]
},
{
"id": "r8-ldi",
"mnemonic": "LDI",
"opcode": 1,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Load an immediate into the accumulator.",
"microOps": [
{
"op": "move",
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"src": {
"kind": "operand",
"field": "value"
}
}
]
},
{
"id": "r8-ldx",
"mnemonic": "LDX",
"opcode": 2,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Load an immediate into the X index register.",
"microOps": [
{
"op": "move",
"dst": {
"kind": "reg",
"bank": "X",
"index": {
"kind": "literal",
"value": 0
}
},
"src": {
"kind": "operand",
"field": "value"
}
}
]
},
{
"id": "r8-lda",
"mnemonic": "LDA",
"opcode": 3,
"words": 2,
"operands": [
{
"name": "source",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Load the accumulator from a memory address (absolute addressing).",
"microOps": [
{
"op": "load",
"memory": "MEM",
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"addr": {
"kind": "operand",
"field": "source"
}
}
]
},
{
"id": "r8-sta",
"mnemonic": "STA",
"opcode": 4,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Store the accumulator to a memory address (absolute addressing).",
"microOps": [
{
"op": "store",
"memory": "MEM",
"addr": {
"kind": "operand",
"field": "target"
},
"src": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
}
}
]
},
{
"id": "r8-stax",
"mnemonic": "STAX",
"opcode": 5,
"words": 2,
"operands": [
{
"name": "base",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Store the accumulator to base + X (indexed addressing).",
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "temp",
"index": 0
},
"a": {
"kind": "operand",
"field": "base"
},
"b": {
"kind": "reg",
"bank": "X",
"index": {
"kind": "literal",
"value": 0
}
},
"setFlags": false
},
{
"op": "store",
"memory": "MEM",
"addr": {
"kind": "temp",
"index": 0
},
"src": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
}
}
]
},
{
"id": "r8-addi",
"mnemonic": "ADDI",
"opcode": 6,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [
"Z",
"N",
"C"
],
"doc": "Add an immediate to the accumulator.",
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"a": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "operand",
"field": "value"
},
"setFlags": true
}
]
},
{
"id": "r8-add",
"mnemonic": "ADD",
"opcode": 7,
"words": 2,
"operands": [
{
"name": "source",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [
"Z",
"N",
"C"
],
"doc": "Add the value at a memory address to the accumulator.",
"microOps": [
{
"op": "load",
"memory": "MEM",
"dst": {
"kind": "temp",
"index": 0
},
"addr": {
"kind": "operand",
"field": "source"
}
},
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"a": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "temp",
"index": 0
},
"setFlags": true
}
]
},
{
"id": "r8-cmp",
"mnemonic": "CMP",
"opcode": 8,
"words": 2,
"operands": [
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [
"Z",
"N",
"C"
],
"doc": "Compare: set flags from ACC - value without changing the accumulator.",
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": {
"kind": "temp",
"index": 1
},
"a": {
"kind": "reg",
"bank": "ACC",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "operand",
"field": "value"
},
"setFlags": true
}
]
},
{
"id": "r8-beq",
"mnemonic": "BEQ",
"opcode": 9,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Branch when the Z flag is set (last result was zero / compare matched).",
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": true,
"target": {
"kind": "operand",
"field": "target"
}
}
]
},
{
"id": "r8-bne",
"mnemonic": "BNE",
"opcode": 10,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Branch when the Z flag is clear.",
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": false,
"target": {
"kind": "operand",
"field": "target"
}
}
]
},
{
"id": "r8-jmp",
"mnemonic": "JMP",
"opcode": 11,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Jump unconditionally.",
"microOps": [
{
"op": "jump",
"target": {
"kind": "operand",
"field": "target"
}
}
]
},
{
"id": "r8-inx",
"mnemonic": "INX",
"opcode": 12,
"words": 1,
"operands": [],
"flagsAffected": [],
"doc": "Increment the X index register.",
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "reg",
"bank": "X",
"index": {
"kind": "literal",
"value": 0
}
},
"a": {
"kind": "reg",
"bank": "X",
"index": {
"kind": "literal",
"value": 0
}
},
"b": {
"kind": "const",
"value": 1
},
"setFlags": false
}
]
}
]
},
"programs": [
{
"id": "r8-demo",
"name": "times table",
"source": "; RETRO-8 demo - fill MEM[96..100] with 10,20,30,40,50\n; using X-indexed stores.\n\n LDX #0 ; X = table index\n LDI #0 ; ACC = running value\nloop:\n ADDI #10 ; next multiple of ten\n STAX 96 ; MEM[96 + X] = ACC\n INX\n CMP #50 ; reached the last entry?\n BNE loop\n HLT ; MEM[96..100] = 10,20,30,40,50\n"
}
]
}
+661
View File
@@ -0,0 +1,661 @@
{
"formatVersion": 3,
"metadata": {
"name": "TOY-CPU",
"author": "WebMetal examples",
"description": "A four-register machine with register-to-register arithmetic, load/store, and branching - one step up from EDU-CORE.",
"createdAt": "2026-07-17T00:00:00.000Z",
"generator": {
"app": "WebMetal",
"version": "0.12.0"
}
},
"graph": {
"nodes": [
{
"id": "note-title",
"type": "comment",
"position": {
"x": 40,
"y": -180
},
"data": {
"name": "NOTE1",
"doc": "",
"params": {
"text": "TOY-CPU - four general registers (R0-R3) instead of a single accumulator.\n\nArithmetic works register-to-register (ADD R0, R1), so the register file has two read ports feeding the ALU. LDR/STR move data between registers and memory; JMP/JNZ redirect the program counter."
}
}
},
{
"id": "note-regfile",
"type": "comment",
"position": {
"x": 940,
"y": 20
},
"data": {
"name": "NOTE2",
"doc": "",
"params": {
"text": "The register files Read A port feeds both the ALU and the memory write path (STR stores a register). Write-back always comes from the ALU result bus."
}
}
},
{
"id": "rf",
"type": "registerFile",
"position": {
"x": 40,
"y": 40
},
"data": {
"name": "R",
"doc": "Four 8-bit general-purpose registers, R0-R3. Read ports A/B feed the ALU; the write port takes ALU results.",
"params": {
"count": 4,
"width": 8
}
}
},
{
"id": "alu",
"type": "alu",
"position": {
"x": 340,
"y": 40
},
"data": {
"name": "ALU",
"doc": "Adds or subtracts two registers and reports Z/N/C to the flags block.",
"params": {
"width": 8
}
}
},
{
"id": "flags",
"type": "flags",
"position": {
"x": 640,
"y": 40
},
"data": {
"name": "FLAGS",
"doc": "Z (zero), N (negative), C (carry / no-borrow). JNZ tests Z.",
"params": {
"flags": "Z,N,C"
}
}
},
{
"id": "pc",
"type": "pc",
"position": {
"x": 40,
"y": 320
},
"data": {
"name": "PC",
"doc": "8-bit program counter addressing MAINs 256 words. JMP and JNZ load it with a target address.",
"params": {
"width": 8
}
}
},
{
"id": "main",
"type": "memory",
"position": {
"x": 340,
"y": 320
},
"data": {
"name": "MAIN",
"doc": "Unified program + data memory: 256 × 8-bit words. LDR/STR read and write it below the program.",
"params": {
"size": 256,
"width": 8
}
}
},
{
"id": "dec",
"type": "decoder",
"position": {
"x": 640,
"y": 320
},
"data": {
"name": "DEC",
"doc": "Splits fetched words into opcode, register selectors, and the immediate/address operand.",
"params": {}
}
},
{
"id": "ctl",
"type": "control",
"position": {
"x": 940,
"y": 320
},
"data": {
"name": "CTL",
"doc": "Drives the register-file write enable, memory read/write, PC increment/load, and flag latch signals.",
"params": {}
}
}
],
"edges": [
{
"id": "pc.out->main.addr",
"source": "pc",
"sourceHandle": "out",
"target": "main",
"targetHandle": "addr",
"kind": "data"
},
{
"id": "main.dout->dec.instr",
"source": "main",
"sourceHandle": "dout",
"target": "dec",
"targetHandle": "instr",
"kind": "data"
},
{
"id": "rf.ra->alu.a",
"source": "rf",
"sourceHandle": "ra",
"target": "alu",
"targetHandle": "a",
"kind": "data"
},
{
"id": "rf.rb->alu.b",
"source": "rf",
"sourceHandle": "rb",
"target": "alu",
"targetHandle": "b",
"kind": "data"
},
{
"id": "alu.result->rf.wdata",
"source": "alu",
"sourceHandle": "result",
"target": "rf",
"targetHandle": "wdata",
"kind": "data"
},
{
"id": "alu.flags->flags.in",
"source": "alu",
"sourceHandle": "flags",
"target": "flags",
"targetHandle": "in",
"kind": "data"
},
{
"id": "rf.ra->main.din",
"source": "rf",
"sourceHandle": "ra",
"target": "main",
"targetHandle": "din",
"kind": "data"
},
{
"id": "dec.imm->pc.next",
"source": "dec",
"sourceHandle": "imm",
"target": "pc",
"targetHandle": "next",
"kind": "data"
},
{
"id": "flags.out->ctl.flags",
"source": "flags",
"sourceHandle": "out",
"target": "ctl",
"targetHandle": "flags",
"kind": "data"
},
{
"id": "dec.ctl->ctl.decoded",
"source": "dec",
"sourceHandle": "ctl",
"target": "ctl",
"targetHandle": "decoded",
"kind": "control"
},
{
"id": "dec.ctl->alu.op",
"source": "dec",
"sourceHandle": "ctl",
"target": "alu",
"targetHandle": "op",
"kind": "control"
},
{
"id": "ctl.signals->rf.wen",
"source": "ctl",
"sourceHandle": "signals",
"target": "rf",
"targetHandle": "wen",
"kind": "control"
},
{
"id": "ctl.signals->rf.sel",
"source": "ctl",
"sourceHandle": "signals",
"target": "rf",
"targetHandle": "sel",
"kind": "control"
},
{
"id": "ctl.signals->pc.inc",
"source": "ctl",
"sourceHandle": "signals",
"target": "pc",
"targetHandle": "inc",
"kind": "control"
},
{
"id": "ctl.signals->pc.load",
"source": "ctl",
"sourceHandle": "signals",
"target": "pc",
"targetHandle": "load",
"kind": "control"
},
{
"id": "ctl.signals->flags.latch",
"source": "ctl",
"sourceHandle": "signals",
"target": "flags",
"targetHandle": "latch",
"kind": "control"
},
{
"id": "ctl.signals->main.write",
"source": "ctl",
"sourceHandle": "signals",
"target": "main",
"targetHandle": "write",
"kind": "control"
},
{
"id": "ctl.signals->main.read",
"source": "ctl",
"sourceHandle": "signals",
"target": "main",
"targetHandle": "read",
"kind": "control"
}
],
"viewport": {
"x": 30,
"y": 210,
"zoom": 0.8
}
},
"isa": {
"description": "TOY-CPU: register-to-register arithmetic over four registers. Register selectors live in the top bits of word 1; addresses take a full word.",
"opcodeField": {
"offset": 0,
"width": 8
},
"instructions": [
{
"id": "toy-hlt",
"mnemonic": "HLT",
"opcode": 0,
"words": 1,
"operands": [],
"flagsAffected": [],
"doc": "Stop the machine.",
"microOps": [
{
"op": "halt"
}
]
},
{
"id": "toy-ldi",
"mnemonic": "LDI",
"opcode": 1,
"words": 2,
"operands": [
{
"name": "rd",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 6,
"width": 2
}
},
{
"name": "value",
"kind": "immediate",
"field": {
"word": 1,
"offset": 0,
"width": 6
}
}
],
"flagsAffected": [],
"doc": "Load a small immediate (0-63) into register rd.",
"microOps": [
{
"op": "move",
"dst": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"src": {
"kind": "operand",
"field": "value"
}
}
]
},
{
"id": "toy-add",
"mnemonic": "ADD",
"opcode": 2,
"words": 2,
"operands": [
{
"name": "rd",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 6,
"width": 2
}
},
{
"name": "rs",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 4,
"width": 2
}
}
],
"flagsAffected": [
"Z",
"N",
"C"
],
"doc": "rd = rd + rs.",
"microOps": [
{
"op": "alu",
"fn": "add",
"width": 8,
"dst": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"a": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"b": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rs"
}
},
"setFlags": true
}
]
},
{
"id": "toy-sub",
"mnemonic": "SUB",
"opcode": 3,
"words": 2,
"operands": [
{
"name": "rd",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 6,
"width": 2
}
},
{
"name": "rs",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 4,
"width": 2
}
}
],
"flagsAffected": [
"Z",
"N",
"C"
],
"doc": "rd = rd - rs.",
"microOps": [
{
"op": "alu",
"fn": "sub",
"width": 8,
"dst": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"a": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"b": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rs"
}
},
"setFlags": true
}
]
},
{
"id": "toy-ldr",
"mnemonic": "LDR",
"opcode": 4,
"words": 3,
"operands": [
{
"name": "rd",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 6,
"width": 2
}
},
{
"name": "source",
"kind": "address",
"field": {
"word": 2,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Load rd from a memory address.",
"microOps": [
{
"op": "load",
"memory": "MAIN",
"dst": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rd"
}
},
"addr": {
"kind": "operand",
"field": "source"
}
}
]
},
{
"id": "toy-str",
"mnemonic": "STR",
"opcode": 5,
"words": 3,
"operands": [
{
"name": "rs",
"kind": "register",
"bank": "R",
"field": {
"word": 1,
"offset": 6,
"width": 2
}
},
{
"name": "target",
"kind": "address",
"field": {
"word": 2,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Store rs to a memory address.",
"microOps": [
{
"op": "store",
"memory": "MAIN",
"addr": {
"kind": "operand",
"field": "target"
},
"src": {
"kind": "reg",
"bank": "R",
"index": {
"kind": "operand",
"field": "rs"
}
}
}
]
},
{
"id": "toy-jmp",
"mnemonic": "JMP",
"opcode": 6,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Jump unconditionally.",
"microOps": [
{
"op": "jump",
"target": {
"kind": "operand",
"field": "target"
}
}
]
},
{
"id": "toy-jnz",
"mnemonic": "JNZ",
"opcode": 7,
"words": 2,
"operands": [
{
"name": "target",
"kind": "address",
"field": {
"word": 1,
"offset": 0,
"width": 8
}
}
],
"flagsAffected": [],
"doc": "Jump when the Z flag is clear (last result was not zero).",
"microOps": [
{
"op": "branch",
"flag": "Z",
"ifSet": false,
"target": {
"kind": "operand",
"field": "target"
}
}
]
}
]
},
"programs": [
{
"id": "toy-demo",
"name": "sum 5..1",
"source": "; TOY-CPU demo - sum the numbers 5..1 into R0.\n; R1 counts down; R2 holds the constant 1.\n\n LDI R0, #0 ; running total\n LDI R1, #5 ; loop counter\n LDI R2, #1 ; decrement amount\nloop:\n ADD R0, R1 ; total += counter\n SUB R1, R2 ; counter -= 1, sets Z at zero\n JNZ loop\n STR R0, 64 ; MEM[64] = 15 (0x0F)\n HLT\n"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
package-lock.json
# Emscripten build output (scripts/build-wasm.sh)
public/wasm/
+4
View File
@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="WebMetal - design custom CPU architectures visually in your browser."
/>
<title>WebMetal</title>
<script>
;(function () {
var theme = 'light'
try {
var stored = localStorage.getItem('webmetal.theme')
if (stored === 'light' || stored === 'dark') {
theme = stored
} else if (matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark'
}
} catch (e) {
/* storage unavailable - keep default */
}
document.documentElement.dataset.theme = theme
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2170
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "webmetal-frontend",
"private": true,
"version": "0.14.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "oxlint",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"prettier": "^3.9.4",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- WebMetal chip mark: an IC package with pins -->
<g stroke="#2874d0" stroke-width="2" stroke-linecap="round">
<path d="M10 5v-3M16 5v-3M22 5v-3" />
<path d="M10 30v-3M16 30v-3M22 30v-3" />
<path d="M5 10h-3M5 16h-3M5 22h-3" />
<path d="M30 10h-3M30 16h-3M30 22h-3" />
</g>
<rect x="5" y="5" width="22" height="22" rx="4" fill="#2874d0" />
<text x="16" y="20.5" font-family="ui-monospace, Consolas, monospace" font-size="9" font-weight="bold" fill="#ffffff" text-anchor="middle">WM</text>
</svg>

After

Width:  |  Height:  |  Size: 586 B

+7
View File
@@ -0,0 +1,7 @@
import { AppShell } from './app/AppShell'
function App() {
return <AppShell />
}
export default App
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react'
import { loadWasmEngine } from '../emulator/wasmLoader'
import { Button } from '../ui/Button'
import { APP_NAME, APP_TAGLINE, appTitle } from './meta'
import { useAppStore } from './store'
/** Help/about dialog (native <dialog>, so Esc/backdrop behavior is free). */
export function AboutDialog() {
const open = useAppStore((s) => s.aboutOpen)
const setAboutOpen = useAppStore((s) => s.setAboutOpen)
const ref = useRef<HTMLDialogElement>(null)
const [engineStatus, setEngineStatus] = useState('checking…')
useEffect(() => {
const dialog = ref.current
if (!dialog) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
if (open) {
void loadWasmEngine().then((result) => {
setEngineStatus(
result.ok ? `${result.engine.version()} (wasm)` : result.reason,
)
})
}
}, [open])
return (
<dialog
ref={ref}
className="about-dialog"
onClose={() => setAboutOpen(false)}
aria-labelledby="about-title"
>
<div className="about-chip" aria-hidden="true">
<span>WM</span>
</div>
<h2 id="about-title">{appTitle(__APP_VERSION__)}</h2>
<p>{APP_TAGLINE}</p>
<p className="about-detail">
{APP_NAME} lets you build a CPU from visual blocks, define its
instruction set, write assembly for it, and run programs in an emulated
debugger - entirely in your browser, with everything exportable as JSON.
</p>
<p className="about-detail">
Open source under the MIT license. Developed by ApfelTeeSaft
</p>
<p className="about-detail" data-testid="engine-status">
Emulator engine: {engineStatus}
</p>
<div className="about-actions">
<Button variant="solid" onClick={() => setAboutOpen(false)} autoFocus>
Close
</Button>
</div>
</dialog>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { ReactFlowProvider } from '@xyflow/react'
import { useCallback, type DragEvent } from 'react'
import './shell.css'
import { GraphCanvas } from '../editor/GraphCanvas'
import { DebuggerView } from '../emulator/DebuggerView'
import { ExamplesDialog } from '../examples/ExamplesDialog'
import { IsaDesigner } from '../isa/IsaDesigner'
import { importProjectText, readFileAsText } from '../model/fileIO'
import { AboutDialog } from './AboutDialog'
import { HelpDialog } from './HelpDialog'
import { BottomPanel, InspectorPanel, ToolboxPanel } from './panels'
import { useGlobalShortcuts } from './shortcuts'
import { useAppStore } from './store'
import { Toolbar } from './Toolbar'
export function AppShell() {
const view = useAppStore((s) => s.view)
useGlobalShortcuts()
const onDragOver = useCallback((event: DragEvent) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}, [])
const onDrop = useCallback((event: DragEvent) => {
const file = Array.from(event.dataTransfer.files).find(
(f) => f.name.endsWith('.json') || f.type === 'application/json',
)
if (!file) return
event.preventDefault()
void readFileAsText(file)
.then(importProjectText)
.catch(() => window.alert('Could not read the dropped file.'))
}, [])
return (
<div className="shell" onDragOver={onDragOver} onDrop={onDrop}>
<Toolbar />
<ReactFlowProvider>
<div className="shell-body">
{view === 'architecture' ? <ToolboxPanel /> : null}
<div className="shell-center">
{/* The canvas stays mounted in ISA view to preserve its state;
it is simply hidden. */}
<div
className="shell-canvas-slot"
style={view === 'architecture' ? undefined : { display: 'none' }}
>
<GraphCanvas />
</div>
{view === 'isa' ? <IsaDesigner /> : null}
{view === 'run' ? <DebuggerView /> : null}
{view === 'architecture' || view === 'run' ? <BottomPanel /> : null}
</div>
<InspectorPanel />
</div>
</ReactFlowProvider>
<AboutDialog />
<ExamplesDialog />
<HelpDialog />
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useRef } from 'react'
import { Button } from '../ui/Button'
import { MOD_LABEL } from './shortcuts'
import { useAppStore } from './store'
/** Getting-started walkthrough + keyboard reference (native <dialog>). */
const REPO_DOCS = 'https://github.com/ApfelTeeSaft/WebMetal/blob/main'
const SHORTCUTS: { keys: string[]; action: string }[] = [
{ keys: [`${MOD_LABEL}+Z`], action: 'Undo (graph & instruction set)' },
{ keys: [`${MOD_LABEL}+Shift+Z`, `${MOD_LABEL}+Y`], action: 'Redo' },
{ keys: ['Delete', 'Backspace'], action: 'Delete selected blocks/wires' },
{ keys: [`${MOD_LABEL}+D`], action: 'Duplicate selected blocks' },
{ keys: [`${MOD_LABEL}+S`], action: 'Export the project as JSON' },
{ keys: [`${MOD_LABEL}+Enter`], action: 'Assemble & load into the debugger' },
{ keys: ['F8'], action: 'Step one instruction' },
{ keys: ['F9'], action: 'Run / pause' },
{ keys: ['?'], action: 'Open this help' },
]
export function HelpDialog() {
const open = useAppStore((s) => s.helpOpen)
const setHelpOpen = useAppStore((s) => s.setHelpOpen)
const setAboutOpen = useAppStore((s) => s.setAboutOpen)
const setExamplesOpen = useAppStore((s) => s.setExamplesOpen)
const ref = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = ref.current
if (!dialog) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
return (
<dialog
ref={ref}
className="help-dialog"
onClose={() => setHelpOpen(false)}
aria-labelledby="help-title"
data-testid="help-dialog"
>
<h2 id="help-title">Getting started</h2>
<ol className="help-steps">
<li>
<strong>Design the machine.</strong> Drag blocks from the Toolbox onto
the canvas - a Program Counter, a Memory, registers, an ALU - and wire
outputs to inputs. The Problems panel (bottom) flags anything
structurally wrong.
</li>
<li>
<strong>Define the instruction set.</strong> In the Instruction Set
tab, give each instruction a mnemonic, an encoding, and its behavior
as a list of micro-ops (move, ALU, load/store, jump).
</li>
<li>
<strong>Write a program.</strong> The Assembly panel highlights and
checks your code against the instruction set you just defined.
</li>
<li>
<strong>Run it.</strong> The Run button assembles the active program
and opens the debugger: step, set breakpoints in the editor gutter,
watch registers, flags, and memory, and edit memory while paused.
</li>
<li>
<strong>Keep it.</strong> Your work autosaves in the browser; Export
writes a single <code>.webmetal.json</code> file that Import (or drag
&amp; drop) restores exactly - layout included.
</li>
</ol>
<p className="help-hint">
New here? Open a bundled example - a complete, documented CPU you can
run immediately and take apart.{' '}
<Button
onClick={() => {
setHelpOpen(false)
setExamplesOpen(true)
}}
>
Browse examples
</Button>
</p>
<h3>Keyboard shortcuts</h3>
<table className="help-shortcuts">
<tbody>
{SHORTCUTS.map((row) => (
<tr key={row.action}>
<td>
{row.keys.map((k, i) => (
<span key={k}>
{i > 0 ? ' or ' : null}
<kbd>{k}</kbd>
</span>
))}
</td>
<td>{row.action}</td>
</tr>
))}
</tbody>
</table>
<p className="help-hint">
You can find the source code in the
repository's{' '}
<a href={REPO_DOCS} target="_blank" rel="noreferrer">
main branch
</a>
.
</p>
<div className="help-actions">
<Button
onClick={() => {
setHelpOpen(false)
setAboutOpen(true)
}}
data-testid="about-open"
>
About &amp; engine status
</Button>
<Button variant="solid" onClick={() => setHelpOpen(false)} autoFocus>
Close
</Button>
</div>
</dialog>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { useRef, type ChangeEvent } from 'react'
import { useGraphStore } from '../editor/graphStore'
import { useDebugStore } from '../emulator/debugStore'
import {
downloadProjectFile,
importProjectText,
readFileAsText,
} from '../model/fileIO'
import { useHistoryStore } from '../model/history'
import { newProject, snapshotProject } from '../model/serialize'
import { Button } from '../ui/Button'
import { HelpIcon, MoonIcon, RedoIcon, SunIcon, UndoIcon } from '../ui/icons'
import { APP_NAME } from './meta'
import { MOD_LABEL } from './shortcuts'
import { useAppStore } from './store'
/**
* Top toolbar: app identity on the left, project actions in the middle,
* view controls on the right.
*/
export function Toolbar() {
const theme = useAppStore((s) => s.theme)
const toggleTheme = useAppStore((s) => s.toggleTheme)
const setHelpOpen = useAppStore((s) => s.setHelpOpen)
const setExamplesOpen = useAppStore((s) => s.setExamplesOpen)
const canUndo = useHistoryStore((s) => s.past.length > 0)
const canRedo = useHistoryStore((s) => s.future.length > 0)
const undo = useHistoryStore((s) => s.undo)
const redo = useHistoryStore((s) => s.redo)
const projectName = useAppStore((s) => s.projectMeta.name)
const view = useAppStore((s) => s.view)
const setView = useAppStore((s) => s.setView)
const fileInputRef = useRef<HTMLInputElement>(null)
const onNew = () => {
const hasContent = useGraphStore.getState().nodes.length > 0
if (
hasContent &&
!window.confirm('Start a new project? Unsaved changes will be lost.')
) {
return
}
newProject()
}
const onExport = () => downloadProjectFile(snapshotProject())
const onImportFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = '' // allow re-importing the same file
if (!file) return
try {
importProjectText(await readFileAsText(file))
} catch {
window.alert('Could not read the selected file.')
}
}
return (
<div className="toolbar" role="toolbar" aria-label="Main toolbar">
<div className="toolbar-brand">
<span className="toolbar-chip" aria-hidden="true">
WM
</span>
<span className="toolbar-title">{APP_NAME}</span>
<span
className="toolbar-project"
title="Current project"
data-testid="toolbar-project-name"
>
{projectName}
</span>
</div>
<div className="toolbar-tabs" role="tablist" aria-label="Main view">
<button
type="button"
role="tab"
aria-selected={view === 'architecture'}
className={view === 'architecture' ? 'tab tab-active' : 'tab'}
onClick={() => setView('architecture')}
data-testid="view-architecture"
>
Architecture
</button>
<button
type="button"
role="tab"
aria-selected={view === 'isa'}
className={view === 'isa' ? 'tab tab-active' : 'tab'}
onClick={() => setView('isa')}
data-testid="view-isa"
>
Instruction Set
</button>
<button
type="button"
role="tab"
aria-selected={view === 'run'}
className={view === 'run' ? 'tab tab-active' : 'tab'}
onClick={() => setView('run')}
data-testid="view-run"
>
Run
</button>
</div>
<div className="toolbar-actions">
<Button
variant="icon"
onClick={undo}
disabled={!canUndo}
title={`Undo (${MOD_LABEL}+Z)`}
aria-label="Undo"
data-testid="action-undo"
>
<UndoIcon />
</Button>
<Button
variant="icon"
onClick={redo}
disabled={!canRedo}
title={`Redo (${MOD_LABEL}+Shift+Z)`}
aria-label="Redo"
data-testid="action-redo"
>
<RedoIcon />
</Button>
<Button
onClick={onNew}
title="Start a new empty project"
data-testid="action-new"
>
New
</Button>
<Button
onClick={() => setExamplesOpen(true)}
title="Open a bundled example project"
data-testid="action-examples"
>
Examples
</Button>
<Button
onClick={() => fileInputRef.current?.click()}
title="Import a .webmetal.json project file"
data-testid="action-import"
>
Import
</Button>
<Button
onClick={onExport}
title="Export the project as a .webmetal.json file"
data-testid="action-export"
>
Export
</Button>
<Button
variant="solid"
onClick={() => {
setView('run')
void useDebugStore.getState().loadIntoEngine()
}}
title="Assemble the active program and open the debugger"
data-testid="action-run"
>
Run
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={onImportFile}
data-testid="import-file-input"
/>
</div>
<div className="toolbar-view">
<Button
variant="icon"
onClick={toggleTheme}
title={
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
}
aria-label={
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
}
data-testid="theme-toggle"
>
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
</Button>
<Button
variant="icon"
onClick={() => setHelpOpen(true)}
title="Help & getting started (?)"
aria-label="Help and getting started"
data-testid="help-open"
>
<HelpIcon />
</Button>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { APP_NAME, appTitle } from './meta'
describe('appTitle', () => {
it('combines the app name and version', () => {
expect(appTitle('0.1.0')).toBe('WebMetal v0.1.0')
})
it('starts with the app name', () => {
expect(appTitle('1.2.3').startsWith(APP_NAME)).toBe(true)
})
})
+11
View File
@@ -0,0 +1,11 @@
/** Application-wide constants and small helpers around app identity. */
export const APP_NAME = 'WebMetal'
export const APP_TAGLINE =
'Design custom CPU architectures visually, in your browser.'
/** Human-readable title string, e.g. "WebMetal v0.1.0". */
export function appTitle(version: string): string {
return `${APP_NAME} v${version}`
}
+221
View File
@@ -0,0 +1,221 @@
import { useState } from 'react'
import { AssemblyTab, OutputTab } from '../asm/AsmPanel'
import { goToLine } from '../asm/editorNav'
import { Inspector } from '../editor/Inspector'
import { Toolbox } from '../editor/Toolbox'
import { Button } from '../ui/Button'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
} from '../ui/icons'
import { PanelHeader } from '../ui/PanelHeader'
import { Tabs } from '../ui/Tabs'
import { useAppStore } from './store'
import { usePanelResize } from './usePanelResize'
function CollapsedRail({
label,
side,
onExpand,
}: {
label: string
side: 'left' | 'right'
onExpand: () => void
}) {
return (
<div className="panel-rail">
<Button
variant="icon"
onClick={onExpand}
title={`Show ${label}`}
aria-label={`Show ${label}`}
>
{side === 'left' ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Button>
<span className="panel-rail-label">{label}</span>
</div>
)
}
export function ToolboxPanel() {
const open = useAppStore((s) => s.panelOpen.toolbox)
const width = useAppStore((s) => s.panelSize.toolbox)
const togglePanel = useAppStore((s) => s.togglePanel)
const resize = usePanelResize('toolbox', 1)
if (!open) {
return (
<CollapsedRail
label="Toolbox"
side="left"
onExpand={() => togglePanel('toolbox')}
/>
)
}
return (
<>
<aside
className="panel panel-toolbox"
style={{ width }}
data-testid="toolbox-panel"
>
<PanelHeader title="Toolbox">
<Button
variant="icon"
onClick={() => togglePanel('toolbox')}
title="Hide toolbox"
aria-label="Hide toolbox"
data-testid="toolbox-collapse"
>
<ChevronLeftIcon />
</Button>
</PanelHeader>
<div className="panel-body">
<Toolbox />
</div>
</aside>
<div
className="resize-handle resize-handle-x"
role="separator"
aria-orientation="vertical"
aria-label="Resize toolbox"
tabIndex={0}
onPointerDown={resize.onPointerDown}
onKeyDown={resize.onKeyDown}
/>
</>
)
}
export function InspectorPanel() {
const open = useAppStore((s) => s.panelOpen.inspector)
const width = useAppStore((s) => s.panelSize.inspector)
const togglePanel = useAppStore((s) => s.togglePanel)
const resize = usePanelResize('inspector', -1)
if (!open) {
return (
<CollapsedRail
label="Inspector"
side="right"
onExpand={() => togglePanel('inspector')}
/>
)
}
return (
<>
<div
className="resize-handle resize-handle-x"
role="separator"
aria-orientation="vertical"
aria-label="Resize inspector"
tabIndex={0}
onPointerDown={resize.onPointerDown}
onKeyDown={resize.onKeyDown}
/>
<aside
className="panel panel-inspector"
style={{ width }}
data-testid="inspector-panel"
>
<PanelHeader title="Inspector">
<Button
variant="icon"
onClick={() => togglePanel('inspector')}
title="Hide inspector"
aria-label="Hide inspector"
>
<ChevronRightIcon />
</Button>
</PanelHeader>
<div className="panel-body">
<Inspector />
</div>
</aside>
</>
)
}
export function BottomPanel() {
const open = useAppStore((s) => s.panelOpen.bottom)
const height = useAppStore((s) => s.panelSize.bottom)
const togglePanel = useAppStore((s) => s.togglePanel)
const resize = usePanelResize('bottom', -1)
const [tab, setTab] = useState('assembly')
if (!open) {
return (
<div className="panel-rail panel-rail-bottom">
<Button
variant="icon"
onClick={() => togglePanel('bottom')}
title="Show assembly &amp; output panel"
aria-label="Show assembly and output panel"
>
<ChevronUpIcon />
</Button>
<span className="panel-rail-label-h">Assembly &amp; Output</span>
</div>
)
}
return (
<>
<div
className="resize-handle resize-handle-y"
role="separator"
aria-orientation="horizontal"
aria-label="Resize bottom panel"
tabIndex={0}
onPointerDown={resize.onPointerDown}
onKeyDown={resize.onKeyDown}
/>
<section
className="panel panel-bottom"
style={{ height }}
data-testid="bottom-panel"
>
<div className="panel-bottom-bar">
<Tabs
items={[
{ id: 'assembly', label: 'Assembly' },
{ id: 'output', label: 'Output' },
]}
active={tab}
onChange={setTab}
/>
<Button
variant="icon"
onClick={() => togglePanel('bottom')}
title="Hide panel"
aria-label="Hide bottom panel"
>
<ChevronDownIcon />
</Button>
</div>
{/* Keep the editor mounted while Output is shown so its state
(undo history, cursor) survives tab switches. */}
<div
className="panel-tab-slot"
style={tab === 'assembly' ? undefined : { display: 'none' }}
>
<AssemblyTab onAssembled={() => setTab('output')} />
</div>
{tab === 'output' ? (
<OutputTab
onNavigate={(line) => {
setTab('assembly')
// Wait for the tab switch to unhide the editor before jumping.
requestAnimationFrame(() => goToLine(line))
}}
/>
) : null}
</section>
</>
)
}
+330
View File
@@ -0,0 +1,330 @@
/* Application frame layout: toolbar, panels, canvas, dialogs. */
.shell {
display: flex;
flex-direction: column;
height: 100svh;
overflow: hidden;
}
/* --- Toolbar --- */
.toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
flex: none;
}
.toolbar-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.toolbar-chip {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: var(--accent);
color: var(--accent-contrast);
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
}
.toolbar-title {
font-weight: 650;
color: var(--text-h);
}
.toolbar-project {
font-size: 13px;
color: var(--text);
border-left: 1px solid var(--border);
padding-left: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
}
.toolbar-view {
display: flex;
align-items: center;
gap: 2px;
}
/* --- Body: side panels + center column --- */
.shell-body {
display: flex;
flex: 1;
min-height: 0;
}
.shell-center {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
.shell-canvas-slot {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
background: var(--bg-panel);
flex: none;
min-height: 0;
}
.panel-toolbox {
border-right: 1px solid var(--border);
}
.panel-inspector {
border-left: 1px solid var(--border);
}
.panel-bottom {
border-top: 1px solid var(--border);
}
.panel-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.panel-placeholder p {
font-size: 13.5px;
color: var(--text);
opacity: 0.85;
max-width: 42ch;
}
.panel-bottom-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
flex: none;
}
.panel-tab-slot {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* Collapsed panel rails */
.panel-rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 6px 2px;
width: 34px;
background: var(--bg-panel);
border-right: 1px solid var(--border);
border-left: 1px solid var(--border);
flex: none;
}
.panel-rail-label {
writing-mode: vertical-rl;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text);
}
.panel-rail-bottom {
flex-direction: row;
width: auto;
height: 32px;
padding: 2px 6px;
border: none;
border-top: 1px solid var(--border);
}
.panel-rail-label-h {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text);
}
/* --- Resize handles --- */
.resize-handle {
flex: none;
background: transparent;
transition: background 0.12s;
}
.resize-handle:hover,
.resize-handle:focus-visible {
background: var(--accent-border);
outline: none;
}
.resize-handle-x {
width: 4px;
cursor: col-resize;
}
.resize-handle-y {
height: 4px;
cursor: row-resize;
}
/* --- About dialog --- */
.about-dialog {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
color: var(--text);
padding: 28px;
max-width: 26rem;
text-align: center;
box-shadow: var(--shadow);
}
.about-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.about-dialog h2 {
margin: 12px 0 4px;
font-size: 22px;
}
.about-detail {
margin-top: 12px;
font-size: 13.5px;
text-align: left;
}
.about-chip {
display: grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0 auto;
border-radius: 12px;
background: var(--accent-bg);
border: 2px solid var(--accent-border);
}
.about-chip span {
font-family: var(--mono);
font-weight: 700;
font-size: 13px;
color: var(--accent);
}
.about-actions {
margin-top: 18px;
}
/* --- Help dialog --- */
.help-dialog {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
color: var(--text);
padding: 24px 28px;
width: min(36rem, calc(100vw - 48px));
max-height: min(85vh, 46rem);
overflow-y: auto;
box-shadow: var(--shadow);
}
.help-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.help-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.help-dialog h3 {
margin: 18px 0 8px;
font-size: 15px;
}
.help-steps {
margin: 0;
padding-left: 20px;
display: grid;
gap: 8px;
font-size: 13.5px;
}
.help-hint {
margin: 12px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.help-shortcuts {
border-collapse: collapse;
font-size: 13px;
width: 100%;
}
.help-shortcuts td {
padding: 4px 12px 4px 0;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.help-shortcuts td:first-child {
white-space: nowrap;
}
.help-dialog kbd {
font-family: var(--mono);
font-size: 11.5px;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 4px;
padding: 1px 5px;
background: var(--bg);
}
.help-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Global keyboard shortcuts, attached once from the AppShell.
*
* Rules of engagement:
* - Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) drive the graph/ISA history - but
* never while focus is in a text control or the CodeMirror editor, which
* keep their own native undo.
* - Ctrl+S (export) and Ctrl+Enter (run) work everywhere: both are safe to
* take over and most wanted mid-typing.
* - Delete/Backspace on the canvas is handled by React Flow itself.
*
* The mapping is documented for users in the Help dialog - keep the two in
* sync when changing anything here.
*/
import { useEffect } from 'react'
import { useDebugStore } from '../emulator/debugStore'
import { useGraphStore } from '../editor/graphStore'
import { useHistoryStore } from '../model/history'
import { downloadProjectFile } from '../model/fileIO'
import { snapshotProject } from '../model/serialize'
import { useAppStore } from './store'
/** Is the event target a control with its own editing/undo behavior? */
function inTextControl(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(
'input, textarea, select, [contenteditable="true"], .cm-editor',
) !== null
)
}
export const IS_MAC =
typeof navigator !== 'undefined' && /Mac|iP/.test(navigator.platform)
/** Platform label for the primary modifier, for UI display. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl'
export function handleShortcut(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey
const key = event.key.toLowerCase()
// Work-anywhere shortcuts.
if (mod && key === 's') {
event.preventDefault()
downloadProjectFile(snapshotProject())
return
}
if (mod && key === 'enter') {
event.preventDefault()
useAppStore.getState().setView('run')
void useDebugStore.getState().loadIntoEngine()
return
}
if (event.key === 'F8') {
event.preventDefault()
useDebugStore.getState().stepOnce()
return
}
if (event.key === 'F9') {
event.preventDefault()
const debug = useDebugStore.getState()
if (debug.status === 'running') debug.pause()
else debug.startRun()
return
}
// Everything below yields to text editing (native undo, native input).
if (inTextControl(event.target)) return
if (mod && key === 'z' && !event.shiftKey) {
event.preventDefault()
useHistoryStore.getState().undo()
return
}
if (mod && (key === 'y' || (key === 'z' && event.shiftKey))) {
event.preventDefault()
useHistoryStore.getState().redo()
return
}
if (mod && key === 'd') {
event.preventDefault()
if (useAppStore.getState().view === 'architecture') {
useGraphStore.getState().duplicateSelection()
}
return
}
if (event.key === '?' && !mod) {
useAppStore.getState().setHelpOpen(true)
}
}
/** Attach the global shortcut handler for the lifetime of the app shell. */
export function useGlobalShortcuts(): void {
useEffect(() => {
window.addEventListener('keydown', handleShortcut)
return () => window.removeEventListener('keydown', handleShortcut)
}, [])
}
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { clampPanelSize, PANEL_LIMITS, useAppStore } from './store'
const initial = useAppStore.getState()
beforeEach(() => {
useAppStore.setState(initial, true)
})
describe('theme actions', () => {
it('toggleTheme flips the theme', () => {
const before = useAppStore.getState().theme
useAppStore.getState().toggleTheme()
expect(useAppStore.getState().theme).not.toBe(before)
useAppStore.getState().toggleTheme()
expect(useAppStore.getState().theme).toBe(before)
})
it('setTheme sets an explicit theme', () => {
useAppStore.getState().setTheme('dark')
expect(useAppStore.getState().theme).toBe('dark')
})
})
describe('panel actions', () => {
it('togglePanel flips only the given side', () => {
useAppStore.getState().togglePanel('toolbox')
const { panelOpen } = useAppStore.getState()
expect(panelOpen.toolbox).toBe(false)
expect(panelOpen.inspector).toBe(true)
expect(panelOpen.bottom).toBe(true)
})
it('setPanelSize clamps to the panel limits', () => {
useAppStore.getState().setPanelSize('toolbox', 10_000)
expect(useAppStore.getState().panelSize.toolbox).toBe(
PANEL_LIMITS.toolbox.max,
)
useAppStore.getState().setPanelSize('toolbox', 1)
expect(useAppStore.getState().panelSize.toolbox).toBe(
PANEL_LIMITS.toolbox.min,
)
})
})
describe('clampPanelSize', () => {
it('passes through in-range values (rounded)', () => {
expect(clampPanelSize('bottom', 200.4)).toBe(200)
})
})
+120
View File
@@ -0,0 +1,120 @@
/**
* App-level Zustand store.
*
* Two slices for now:
* - UI slice: theme, panel visibility/sizes, dialogs. Purely presentational
* state; nothing here belongs in an exported project file.
* - Project slice: placeholder until Phase 4 introduces the versioned
* project document (metadata, graph, ISA, programs).
*/
import { create } from 'zustand'
import { applyTheme, nextTheme, readInitialTheme, type Theme } from './theme'
export type PanelSide = 'toolbox' | 'inspector' | 'bottom'
/** Sizing limits per panel, px. Keeps resize drags within usable bounds. */
export const PANEL_LIMITS: Record<
PanelSide,
{ min: number; max: number; initial: number }
> = {
toolbox: { min: 160, max: 400, initial: 224 },
inspector: { min: 200, max: 480, initial: 280 },
bottom: { min: 120, max: 480, initial: 200 },
}
export function clampPanelSize(side: PanelSide, px: number): number {
const { min, max } = PANEL_LIMITS[side]
return Math.min(max, Math.max(min, Math.round(px)))
}
export type AppView = 'architecture' | 'isa' | 'run'
interface UiSlice {
theme: Theme
/** Which main view fills the center area. */
view: AppView
/** Panel visibility, keyed by side. */
panelOpen: Record<PanelSide, boolean>
/** Panel size in px (width for side panels, height for the bottom panel). */
panelSize: Record<PanelSide, number>
aboutOpen: boolean
examplesOpen: boolean
helpOpen: boolean
setTheme: (theme: Theme) => void
toggleTheme: () => void
setView: (view: AppView) => void
togglePanel: (side: PanelSide) => void
setPanelSize: (side: PanelSide, px: number) => void
setAboutOpen: (open: boolean) => void
setExamplesOpen: (open: boolean) => void
setHelpOpen: (open: boolean) => void
}
export interface ProjectMeta {
name: string
author: string
description: string
/** ISO timestamp set when the project is created; preserved on import. */
createdAt: string
}
export const DEFAULT_PROJECT_META: ProjectMeta = {
name: 'Untitled CPU',
author: '',
description: '',
createdAt: '',
}
interface ProjectSlice {
/** Project metadata; the graph itself lives in the editor's graph store. */
projectMeta: ProjectMeta
setProjectMeta: (patch: Partial<ProjectMeta>) => void
}
export type AppState = UiSlice & ProjectSlice
export const useAppStore = create<AppState>()((set) => ({
// --- UI slice ---
theme: readInitialTheme(),
view: 'architecture',
panelOpen: { toolbox: true, inspector: true, bottom: true },
panelSize: {
toolbox: PANEL_LIMITS.toolbox.initial,
inspector: PANEL_LIMITS.inspector.initial,
bottom: PANEL_LIMITS.bottom.initial,
},
aboutOpen: false,
examplesOpen: false,
helpOpen: false,
setTheme: (theme) => {
applyTheme(theme)
set({ theme })
},
toggleTheme: () => {
set((state) => {
const theme = nextTheme(state.theme)
applyTheme(theme)
return { theme }
})
},
setView: (view) => set({ view }),
togglePanel: (side) => {
set((state) => ({
panelOpen: { ...state.panelOpen, [side]: !state.panelOpen[side] },
}))
},
setPanelSize: (side, px) => {
set((state) => ({
panelSize: { ...state.panelSize, [side]: clampPanelSize(side, px) },
}))
},
setAboutOpen: (open) => set({ aboutOpen: open }),
setExamplesOpen: (open) => set({ examplesOpen: open }),
setHelpOpen: (open) => set({ helpOpen: open }),
// --- Project slice ---
projectMeta: { ...DEFAULT_PROJECT_META },
setProjectMeta: (patch) =>
set((state) => ({ projectMeta: { ...state.projectMeta, ...patch } })),
}))
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isTheme, nextTheme, resolveInitialTheme } from './theme'
describe('isTheme', () => {
it('accepts the two valid themes', () => {
expect(isTheme('light')).toBe(true)
expect(isTheme('dark')).toBe(true)
})
it('rejects anything else', () => {
expect(isTheme('blue')).toBe(false)
expect(isTheme(null)).toBe(false)
expect(isTheme(undefined)).toBe(false)
})
})
describe('nextTheme', () => {
it('toggles between light and dark', () => {
expect(nextTheme('light')).toBe('dark')
expect(nextTheme('dark')).toBe('light')
})
})
describe('resolveInitialTheme', () => {
it('prefers a valid stored choice over the OS preference', () => {
expect(resolveInitialTheme('light', true)).toBe('light')
expect(resolveInitialTheme('dark', false)).toBe('dark')
})
it('falls back to the OS preference for missing or invalid values', () => {
expect(resolveInitialTheme(null, true)).toBe('dark')
expect(resolveInitialTheme(null, false)).toBe('light')
expect(resolveInitialTheme('garbage', true)).toBe('dark')
})
})
+58
View File
@@ -0,0 +1,58 @@
/**
* Light/dark theme handling.
*
* The active theme lives as `data-theme` on <html>. An inline script in
* index.html applies it before first paint (no flash); this module owns it
* afterwards. The user's explicit choice persists in localStorage.
*/
export type Theme = 'light' | 'dark'
export const THEME_STORAGE_KEY = 'webmetal.theme'
export function isTheme(value: unknown): value is Theme {
return value === 'light' || value === 'dark'
}
export function nextTheme(theme: Theme): Theme {
return theme === 'light' ? 'dark' : 'light'
}
/** Pure resolution rule: explicit stored choice wins, else the OS preference. */
export function resolveInitialTheme(
stored: string | null,
prefersDark: boolean,
): Theme {
if (isTheme(stored)) return stored
return prefersDark ? 'dark' : 'light'
}
/** Read the initial theme in a browser; safe to call in non-DOM tests. */
export function readInitialTheme(): Theme {
if (typeof document === 'undefined') return 'light'
// The index.html inline script already resolved storage + OS preference.
const applied = document.documentElement.dataset['theme']
if (isTheme(applied)) return applied
let stored: string | null = null
try {
stored = window.localStorage.getItem(THEME_STORAGE_KEY)
} catch {
// Storage can be unavailable (e.g. blocked); fall through to OS preference.
}
return resolveInitialTheme(
stored,
window.matchMedia('(prefers-color-scheme: dark)').matches,
)
}
/** Apply the theme to the document and persist the choice. */
export function applyTheme(theme: Theme): void {
if (typeof document !== 'undefined') {
document.documentElement.dataset['theme'] = theme
}
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Persisting is best-effort.
}
}
+59
View File
@@ -0,0 +1,59 @@
import { useCallback } from 'react'
import type { PointerEvent as ReactPointerEvent, KeyboardEvent } from 'react'
import { useAppStore, type PanelSide } from './store'
/**
* Pointer + keyboard resize behavior for a panel divider.
*
* `grow` is the drag direction (in the panel's axis) that makes the panel
* larger: +1 when dragging right/down grows it (toolbox), -1 when dragging
* left/up grows it (inspector, bottom panel).
*/
export function usePanelResize(side: PanelSide, grow: 1 | -1) {
const setPanelSize = useAppStore((s) => s.setPanelSize)
const axis = side === 'bottom' ? 'y' : 'x'
const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
event.preventDefault()
const target = event.currentTarget
target.setPointerCapture(event.pointerId)
const startPos = axis === 'x' ? event.clientX : event.clientY
const startSize = useAppStore.getState().panelSize[side]
const onMove = (move: globalThis.PointerEvent) => {
const pos = axis === 'x' ? move.clientX : move.clientY
setPanelSize(side, startSize + grow * (pos - startPos))
}
const onUp = () => {
target.removeEventListener('pointermove', onMove)
target.removeEventListener('pointerup', onUp)
target.removeEventListener('pointercancel', onUp)
}
target.addEventListener('pointermove', onMove)
target.addEventListener('pointerup', onUp)
target.addEventListener('pointercancel', onUp)
},
[axis, grow, setPanelSize, side],
)
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
const step = 16
const size = useAppStore.getState().panelSize[side]
const growKey = axis === 'x' ? 'ArrowRight' : 'ArrowDown'
const shrinkKey = axis === 'x' ? 'ArrowLeft' : 'ArrowUp'
if (event.key === growKey) {
setPanelSize(side, size + grow * step)
event.preventDefault()
} else if (event.key === shrinkKey) {
setPanelSize(side, size - grow * step)
event.preventDefault()
}
},
[axis, grow, setPanelSize, side],
)
return { onPointerDown, onKeyDown }
}
+188
View File
@@ -0,0 +1,188 @@
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands'
import { linter, lintGutter, type Diagnostic } from '@codemirror/lint'
import { Compartment, EditorState } from '@codemirror/state'
import {
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view'
import { useEffect, useMemo, useRef } from 'react'
import { useDebugStore } from '../emulator/debugStore'
import type { IsaDefinition } from '../isa/isaModel'
import type { MachineModel } from '../machine/machineModel'
import { assemble } from './assembler'
import { asmHighlighting, makeAsmLanguage } from './asmLanguage'
import {
breakpointGutter,
execLineField,
setBreakpointLinesEffect,
} from './debugExtensions'
import { registerEditorView, unregisterEditorView } from './editorNav'
import { useProgramStore } from './programStore'
/**
* The CodeMirror 6 editor bound to one program. The language (from the ISA)
* and theme flag are swapped through compartments; lint diagnostics come
* straight from the assembler.
*/
function makeLinter(
isa: () => IsaDefinition,
model: () => MachineModel | null,
) {
return linter((view) => {
const source = view.state.doc.toString()
const currentModel = model()
if (source.trim().length === 0) return []
if (!currentModel) {
return [
{
from: 0,
to: 0,
severity: 'info' as const,
message:
'The architecture design does not compile yet - assembly checking is disabled until it does.',
},
]
}
const result = assemble(source, isa(), currentModel)
return result.diagnostics.map((d): Diagnostic => {
const line = view.state.doc.line(
Math.max(1, Math.min(d.line, view.state.doc.lines)),
)
const from = Math.min(line.from + Math.max(0, d.column - 1), line.to)
// Highlight to the end of the offending token
const rest = view.state.doc.sliceString(from, line.to)
const tokenLength = /^[^\s,]+/.exec(rest)?.[0]?.length ?? 0
return {
from,
to: Math.min(from + Math.max(tokenLength, 1), line.to),
severity: d.severity,
message: d.message,
}
})
})
}
export function AsmEditor({
programId,
source,
isa,
model,
dark,
}: {
programId: string
source: string
isa: IsaDefinition
model: MachineModel | null
dark: boolean
}) {
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const compartments = useMemo(
() => ({ language: new Compartment(), theme: new Compartment() }),
[],
)
// Refs so the (long-lived) linter closure always sees current values.
const isaRef = useRef(isa)
const modelRef = useRef(model)
isaRef.current = isa
modelRef.current = model
const programRef = useRef(programId)
// Create the editor once.
useEffect(() => {
if (!hostRef.current) return
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc:
useProgramStore
.getState()
.programs.find((p) => p.id === programRef.current)?.source ?? '',
extensions: [
breakpointGutter((line) =>
useDebugStore
.getState()
.toggleBreakpointLine(programRef.current, line),
),
execLineField,
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
compartments.language.of(
makeAsmLanguage(isaRef.current, modelRef.current),
),
asmHighlighting,
lintGutter(),
makeLinter(
() => isaRef.current,
() => modelRef.current,
),
compartments.theme.of(EditorView.theme({}, { dark })),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
useProgramStore
.getState()
.updateSource(programRef.current, update.state.doc.toString())
}
}),
],
}),
})
viewRef.current = view
registerEditorView(view)
return () => {
unregisterEditorView(view)
viewRef.current = null
view.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
}, [])
// Swap language when the ISA or model changes.
useEffect(() => {
viewRef.current?.dispatch({
effects: compartments.language.reconfigure(makeAsmLanguage(isa, model)),
})
}, [isa, model, compartments])
// Follow the app theme.
useEffect(() => {
viewRef.current?.dispatch({
effects: compartments.theme.reconfigure(EditorView.theme({}, { dark })),
})
}, [dark, compartments])
// Switch programs / adopt external source changes (import, restore).
useEffect(() => {
programRef.current = programId
const view = viewRef.current
if (!view) return
if (view.state.doc.toString() !== source) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: source },
})
}
}, [programId, source])
// Mirror the active program's breakpoint lines into the gutter.
const breakpointLines = useDebugStore((s) => s.breakpointLines)
useEffect(() => {
viewRef.current?.dispatch({
effects: setBreakpointLinesEffect.of(breakpointLines[programId] ?? []),
})
}, [breakpointLines, programId])
return <div ref={hostRef} className="asm-editor" data-testid="asm-editor" />
}
+227
View File
@@ -0,0 +1,227 @@
import { useMemo } from 'react'
import { useAppStore } from '../app/store'
import { useGraphStore } from '../editor/graphStore'
import { useIsaStore } from '../isa/isaStore'
import { compileGraph } from '../machine/compileGraph'
import { Button } from '../ui/Button'
import './asm.css'
import { assemble } from './assembler'
import { AsmEditor } from './AsmEditor'
import { activeProgramOf, useProgramStore } from './programStore'
/**
* Content of the bottom panel's tabs: the Assembly tab (program bar +
* CodeMirror editor) and the Output tab (assemble results / error list).
*/
function ProgramBar({ onAssemble }: { onAssemble: () => void }) {
const programs = useProgramStore((s) => s.programs)
const active = useProgramStore(activeProgramOf)
const setActive = useProgramStore((s) => s.setActive)
const addProgram = useProgramStore((s) => s.addProgram)
const renameProgram = useProgramStore((s) => s.renameProgram)
const removeProgram = useProgramStore((s) => s.removeProgram)
const onRename = () => {
if (!active) return
const name = window.prompt('Program name:', active.name)
if (name) renameProgram(active.id, name)
}
const onRemove = () => {
if (!active) return
if (window.confirm(`Delete program "${active.name}"?`)) {
removeProgram(active.id)
}
}
return (
<div className="asm-program-bar">
<select
aria-label="Program"
data-testid="program-select"
value={active?.id ?? ''}
onChange={(e) => setActive(e.target.value)}
>
{programs.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
variant="icon"
onClick={addProgram}
title="New program"
aria-label="New program"
data-testid="program-add"
>
+
</Button>
<Button
variant="icon"
onClick={onRename}
disabled={!active}
title="Rename program"
aria-label="Rename program"
data-testid="program-rename"
>
</Button>
<Button
variant="icon"
onClick={onRemove}
disabled={!active}
title="Delete program"
aria-label="Delete program"
data-testid="program-delete"
>
</Button>
<span className="asm-bar-spacer" />
<Button
variant="solid"
onClick={onAssemble}
disabled={!active}
data-testid="assemble-button"
>
Assemble
</Button>
</div>
)
}
export function AssemblyTab({ onAssembled }: { onAssembled: () => void }) {
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const isa = useIsaStore((s) => s.isa)
const active = useProgramStore(activeProgramOf)
const setResult = useProgramStore((s) => s.setResult)
const theme = useAppStore((s) => s.theme)
const compiled = useMemo(() => compileGraph(nodes, edges), [nodes, edges])
const onAssemble = () => {
if (!active) return
if (!compiled.model) {
window.alert(
'The architecture design does not compile - fix the design errors first (see the Instruction Set view).',
)
return
}
setResult(active.id, assemble(active.source, isa, compiled.model))
onAssembled()
}
return (
<div className="asm-tab">
<ProgramBar onAssemble={onAssemble} />
{active ? (
<AsmEditor
programId={active.id}
source={active.source}
isa={isa}
model={compiled.model}
dark={theme === 'dark'}
/>
) : (
<div className="asm-empty">
<p>No programs in this project - add one with +.</p>
</div>
)}
</div>
)
}
export function OutputTab({
onNavigate,
}: {
/** Jump to a source line (switches back to the Assembly tab first). */
onNavigate: (line: number) => void
}) {
const entry = useProgramStore((s) => s.result)
const programs = useProgramStore((s) => s.programs)
if (!entry) {
return (
<div className="asm-empty">
<p>Nothing assembled yet - press Assemble in the Assembly tab.</p>
</div>
)
}
const program = programs.find((p) => p.id === entry.programId)
const { result } = entry
const errors = result.diagnostics.filter((d) => d.severity === 'error')
const warnings = result.diagnostics.filter((d) => d.severity === 'warning')
const totalWords = result.segments.reduce(
(sum, s) => sum + s.values.length,
0,
)
const hex = (v: number) => v.toString(16).toUpperCase().padStart(2, '0')
return (
<div className="asm-output" data-testid="asm-output">
<p
className={
result.ok ? 'asm-status asm-status-ok' : 'asm-status asm-status-err'
}
>
{result.ok
? `✓ "${program?.name ?? '?'}" assembled: ${totalWords} word(s) in ${result.segments.length} segment(s).`
: `✗ "${program?.name ?? '?'}" failed to assemble: ${errors.length} error(s).`}
</p>
{result.diagnostics.length > 0 ? (
<ul className="asm-diags">
{[...errors, ...warnings].map((d, i) => (
<li key={i}>
<button
type="button"
className={`asm-diag asm-diag-${d.severity}`}
onClick={() => onNavigate(d.line)}
title="Jump to line"
>
<span className="asm-diag-pos">
{d.line}:{d.column}
</span>{' '}
{d.message}
</button>
</li>
))}
</ul>
) : null}
{result.ok && result.segments.length > 0 ? (
<div className="asm-dump">
{result.segments.map((segment) => (
<div key={segment.address} className="asm-segment">
<span className="asm-seg-addr">
@{segment.address.toString(16).toUpperCase().padStart(4, '0')}
</span>
<span className="asm-seg-bytes">
{segment.values.map((v, i) => (
<span key={i} className="asm-byte">
{hex(v)}
</span>
))}
</span>
</div>
))}
{Object.keys(result.labels).length > 0 ? (
<p className="asm-labels">
labels:{' '}
{Object.entries(result.labels)
.map(
([name, addr]) =>
`${name}=0x${addr.toString(16).toUpperCase()}`,
)
.join(' ')}
</p>
) : null}
</div>
) : null}
</div>
)
}
+177
View File
@@ -0,0 +1,177 @@
.asm-tab {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.asm-program-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
flex: none;
}
.asm-program-bar select {
font: 12.5px var(--mono);
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 6px;
max-width: 160px;
}
.asm-bar-spacer {
flex: 1;
}
.asm-editor {
flex: 1;
min-height: 0;
overflow: hidden;
}
.asm-editor .cm-editor {
height: 100%;
background: var(--bg-panel);
color: var(--text-h);
font-size: 13px;
}
.asm-editor .cm-editor.cm-focused {
outline: none;
}
.asm-editor .cm-scroller {
font-family: var(--mono);
line-height: 1.5;
}
.asm-editor .cm-gutters {
background: var(--bg);
color: var(--text);
border-right: 1px solid var(--border);
}
.asm-editor .cm-activeLine {
background: var(--hover-bg);
}
.asm-editor .cm-activeLineGutter {
background: var(--hover-bg);
}
.asm-empty {
flex: 1;
display: grid;
place-items: center;
color: var(--text);
font-size: 13px;
padding: 12px;
}
/* --- Output tab --- */
.asm-output {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.asm-status {
font-size: 13px;
font-weight: 600;
}
.asm-status-ok {
color: #2e9e44;
}
.asm-status-err {
color: #d64545;
}
:root[data-theme='dark'] .asm-status-ok {
color: #6fd784;
}
:root[data-theme='dark'] .asm-status-err {
color: #ff8a8a;
}
.asm-diags {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.asm-diag {
font: 12.5px var(--mono);
text-align: left;
background: none;
border: none;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
color: var(--text);
}
.asm-diag:hover {
background: var(--hover-bg);
}
.asm-diag-error {
color: #d64545;
}
:root[data-theme='dark'] .asm-diag-error {
color: #ff8a8a;
}
.asm-diag-pos {
opacity: 0.7;
}
.asm-dump {
display: flex;
flex-direction: column;
gap: 4px;
font-family: var(--mono);
font-size: 12.5px;
}
.asm-segment {
display: flex;
gap: 10px;
align-items: baseline;
}
.asm-seg-addr {
color: var(--accent);
flex: none;
}
.asm-seg-bytes {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.asm-byte {
color: var(--text-h);
}
.asm-labels {
color: var(--text);
font-size: 12px;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { IsaDefinition } from '../isa/isaModel'
import type { MachineModel } from '../machine/machineModel'
import { buildTokenSets, classifyWord } from './asmLanguage'
const isa: IsaDefinition = {
description: '',
opcodeField: { offset: 0, width: 8 },
instructions: [
{
id: 'a',
mnemonic: 'LDI',
opcode: 1,
words: 1,
operands: [],
flagsAffected: [],
doc: '',
microOps: [],
},
],
}
const model: MachineModel = {
modelVersion: 1,
banks: [
{ name: 'R', width: 8, count: 2 },
{ name: 'ACC', width: 8, count: 1 },
],
memories: [{ name: 'MAIN', size: 16, width: 8 }],
flags: [],
pc: { width: 8 },
programMemory: 'MAIN',
}
describe('buildTokenSets', () => {
it('collects lower-cased mnemonics, register names, and directives', () => {
const sets = buildTokenSets(isa, model)
expect(sets.mnemonics.has('ldi')).toBe(true)
expect(sets.registers.has('r0')).toBe(true)
expect(sets.registers.has('r1')).toBe(true)
expect(sets.registers.has('acc')).toBe(true)
expect(sets.registers.has('r2')).toBe(false)
expect(sets.directives.has('.org')).toBe(true)
})
it('works without a model (no registers)', () => {
const sets = buildTokenSets(isa, null)
expect(sets.registers.size).toBe(0)
})
})
describe('classifyWord', () => {
const sets = buildTokenSets(isa, model)
it('classifies labels, mnemonics, registers, and plain identifiers', () => {
expect(classifyWord('start', true, sets)).toBe('labelName')
expect(classifyWord('LDI', false, sets)).toBe('keyword')
expect(classifyWord('ldi', false, sets)).toBe('keyword')
expect(classifyWord('R1', false, sets)).toBe('typeName')
expect(classifyWord('acc', false, sets)).toBe('typeName')
expect(classifyWord('someLabelRef', false, sets)).toBe('variableName')
})
})
+110
View File
@@ -0,0 +1,110 @@
import {
StreamLanguage,
syntaxHighlighting,
HighlightStyle,
} from '@codemirror/language'
import type { Extension } from '@codemirror/state'
import { tags } from '@lezer/highlight'
import type { IsaDefinition } from '../isa/isaModel'
import type { MachineModel } from '../machine/machineModel'
import { registerNames } from './syntax'
export interface TokenSets {
mnemonics: Set<string>
registers: Set<string>
directives: Set<string>
}
/** Lower-cased word sets the tokenizer classifies against. */
export function buildTokenSets(
isa: IsaDefinition,
model: MachineModel | null,
): TokenSets {
return {
mnemonics: new Set(isa.instructions.map((i) => i.mnemonic.toLowerCase())),
registers: new Set(
(model?.banks ?? []).flatMap((bank) =>
registerNames(bank).map((n) => n.toLowerCase()),
),
),
directives: new Set(['.org', '.word', '.byte']),
}
}
const NUMBER_RE = /^(?:0[xX][0-9a-fA-F]+|\$[0-9a-fA-F]+|0[bB][01]+|\d+)/
const IDENT_START_RE = /^[A-Za-z_]/
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*/
const DIRECTIVE_RE = /^\.[A-Za-z_][A-Za-z0-9_]*/
/**
* Classify one raw word. Exported for tests; the stream tokenizer feeds it.
* Returns CodeMirror stream-parser token names.
*/
export function classifyWord(
word: string,
followedByColon: boolean,
sets: TokenSets,
): string {
if (followedByColon) return 'labelName'
const lower = word.toLowerCase()
if (sets.mnemonics.has(lower)) return 'keyword'
if (sets.registers.has(lower)) return 'typeName'
return 'variableName'
}
/** The language extension (highlight-only; parsing stays in the assembler). */
export function makeAsmLanguage(
isa: IsaDefinition,
model: MachineModel | null,
): Extension {
const sets = buildTokenSets(isa, model)
const language = StreamLanguage.define<{ unused?: never }>({
name: 'webmetal-asm',
startState: () => ({}),
token(stream) {
if (stream.eatSpace()) return null
if (stream.peek() === ';') {
stream.skipToEnd()
return 'comment'
}
if (stream.match(NUMBER_RE)) return 'number'
if (stream.match(DIRECTIVE_RE)) {
return sets.directives.has(stream.current().toLowerCase())
? 'meta'
: 'invalid'
}
if (IDENT_START_RE.test(stream.peek() ?? '')) {
stream.match(IDENT_RE)
const followedByColon = /^\s*:/.test(stream.string.slice(stream.pos))
return classifyWord(stream.current(), followedByColon, sets)
}
stream.next()
const ch = stream.current()
if (ch === '#') return 'operator'
if (ch === ',' || ch === ':') return 'punctuation'
return null
},
})
return language
}
/**
* Token colors via CSS custom properties (defined per theme in index.css),
* so one HighlightStyle serves both light and dark.
*/
export const asmHighlighting: Extension = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.comment, color: 'var(--asm-comment)', fontStyle: 'italic' },
{ tag: tags.keyword, color: 'var(--asm-mnemonic)', fontWeight: '600' },
{ tag: tags.typeName, color: 'var(--asm-register)' },
{ tag: tags.number, color: 'var(--asm-number)' },
{ tag: tags.labelName, color: 'var(--asm-label)', fontWeight: '600' },
{ tag: tags.meta, color: 'var(--asm-directive)' },
{ tag: tags.variableName, color: 'var(--asm-ident)' },
{ tag: tags.operator, color: 'var(--asm-number)' },
{ tag: tags.invalid, color: 'var(--asm-invalid)' },
]),
)
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest'
import type { Instruction, IsaDefinition } from '../isa/isaModel'
import type { MachineModel } from '../machine/machineModel'
import { assemble } from './assembler'
/**
* Test ISA (8-bit words, opcode = full word 0):
* LDI rd, #imm [op=1] [rd:w1 b6-7 | imm:w1 b0-5] 2 words
* ADD rd, rs [op=2] [rd:w1 b6-7 | rs:w1 b4-5] 2 words
* JMP addr [op=3] [addr:w1 b0-7] 2 words
* HLT [op=4] 1 word
*/
let n = 0
function instr(patch: Partial<Instruction>): Instruction {
return {
id: `i${++n}`,
mnemonic: 'X',
opcode: 0,
words: 1,
operands: [],
flagsAffected: [],
doc: '',
microOps: [],
...patch,
}
}
const isa: IsaDefinition = {
description: 'test',
opcodeField: { offset: 0, width: 8 },
instructions: [
instr({
mnemonic: 'LDI',
opcode: 1,
words: 2,
operands: [
{
name: 'rd',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 6, width: 2 },
},
{
name: 'imm',
kind: 'immediate',
field: { word: 1, offset: 0, width: 6 },
},
],
}),
instr({
mnemonic: 'ADD',
opcode: 2,
words: 2,
operands: [
{
name: 'rd',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 6, width: 2 },
},
{
name: 'rs',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 4, width: 2 },
},
],
}),
instr({
mnemonic: 'JMP',
opcode: 3,
words: 2,
operands: [
{
name: 'addr',
kind: 'address',
field: { word: 1, offset: 0, width: 8 },
},
],
}),
instr({ mnemonic: 'HLT', opcode: 4, words: 1 }),
],
}
const model: MachineModel = {
modelVersion: 1,
banks: [
{ name: 'R', width: 8, count: 4 },
{ name: 'ACC', width: 8, count: 1 },
],
memories: [{ name: 'MAIN', size: 256, width: 8 }],
flags: ['Z', 'N', 'C'],
pc: { width: 16 },
programMemory: 'MAIN',
}
const errorsOf = (source: string) =>
assemble(source, isa, model)
.diagnostics.filter((d) => d.severity === 'error')
.map((d) => `${d.line}:${d.column} ${d.message}`)
describe('assemble: happy path', () => {
it('assembles a program with labels, data, and .org', () => {
const source = [
'; sum demo',
'start:',
' LDI R0, #5 ; rd=0 imm=5',
' ADD R0, R1',
'loop: JMP loop',
' HLT',
' .org 0x10',
'value: .word 42, 0xFF',
' .byte 1, 2',
' LDI R1, #value ; label as immediate',
].join('\n')
const result = assemble(source, isa, model)
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
expect(result.ok).toBe(true)
expect(result.segments).toEqual([
{
address: 0,
values: [
1,
5, // LDI R0,#5 -> op, rd=0<<6|5
2,
0b0001_0000, // ADD R0,R1 -> rd=0,rs=1 -> bits 4-5 = 1
3,
4, // JMP loop (loop = 4)
4, // HLT
],
},
{
address: 0x10,
values: [42, 0xff, 1, 2, 1, (1 << 6) | 0x10], // data + LDI R1,#0x10
},
])
expect(result.labels).toEqual({ start: 0, loop: 4, value: 0x10 })
expect(result.sourceMap).toEqual([
{ address: 0, line: 3, words: 2 },
{ address: 2, line: 4, words: 2 },
{ address: 4, line: 5, words: 2 },
{ address: 6, line: 6, words: 1 },
{ address: 0x10, line: 8, words: 2 },
{ address: 0x12, line: 9, words: 2 },
{ address: 0x14, line: 10, words: 2 },
])
})
it('is case-insensitive for mnemonics, registers, and labels', () => {
const result = assemble('Start: ldi r2, #1\n jmp START', isa, model)
expect(result.ok).toBe(true)
expect(result.segments[0]?.values).toEqual([1, (2 << 6) | 1, 3, 0])
})
it('accepts hex, binary, and $ literals', () => {
const result = assemble('LDI R0, #0x0A\nJMP $10\n.word 0b111', isa, model)
expect(result.ok).toBe(true)
expect(result.segments[0]?.values).toEqual([1, 10, 3, 16, 7])
})
it('assembles an empty/comment-only source to nothing', () => {
const result = assemble('; nothing here\n\n', isa, model)
expect(result.ok).toBe(true)
expect(result.segments).toEqual([])
})
})
describe('assemble: errors', () => {
it('rejects unknown mnemonics with a helpful list', () => {
const [message] = errorsOf(' FOO R0')
expect(message).toContain('unknown instruction "FOO"')
expect(message).toContain('LDI')
})
it('rejects wrong operand counts', () => {
expect(errorsOf('LDI R0').join()).toContain('expects 2 operand(s), got 1')
expect(errorsOf('HLT 5').join()).toContain('expects 0 operand(s), got 1')
})
it('enforces immediate (#) and address (no #) syntax', () => {
expect(errorsOf('LDI R0, 5').join()).toContain('write it as #5')
expect(errorsOf('JMP #3').join()).toContain("without '#'")
})
it('rejects bad registers with the expected range', () => {
expect(errorsOf('LDI R9, #1').join()).toContain(
'expected a "R" register (R0…R3), got "R9"',
)
expect(errorsOf('LDI ACC, #1').join()).toContain('got "ACC"')
})
it('range-checks immediates against the field width', () => {
expect(errorsOf('LDI R0, #64').join()).toContain(
'value 64 does not fit operand "imm" (6 bits, max 63)',
)
})
it('rejects duplicate and undefined labels', () => {
expect(errorsOf('a:\na: HLT').join()).toContain('duplicate label "a"')
expect(errorsOf('JMP nowhere').join()).toContain(
'undefined label "nowhere"',
)
})
it('rejects bad directives and bad .org addresses', () => {
expect(errorsOf('.foo 1').join()).toContain('unknown directive ".foo"')
expect(errorsOf('.org nope').join()).toContain('.org needs a number')
expect(errorsOf('.org 999').join()).toContain('outside memory')
})
it('range-checks data values', () => {
expect(errorsOf('.byte 256').join()).toContain('does not fit a byte')
expect(errorsOf('.word 300').join()).toContain('does not fit a 8-bit word')
})
it('detects overlapping writes from .org', () => {
const source = 'HLT\n.org 0\n.word 9'
expect(errorsOf(source).join()).toContain('address 0 written twice')
})
it('detects programs that exceed memory', () => {
const source = '.org 255\nLDI R0, #1' // 2 words at 255 -> 255,256
expect(errorsOf(source).join()).toContain('program does not fit')
})
it('reports line and column positions', () => {
const result = assemble(' HLT\n LDI R9, #1', isa, model)
const diag = result.diagnostics.find((d) => d.severity === 'error')
expect(diag?.line).toBe(2)
expect(diag?.column).toBe(7) // "R9" starts at column 7
})
it('produces no segments when any error exists', () => {
const result = assemble('HLT\nFOO', isa, model)
expect(result.ok).toBe(false)
expect(result.segments).toEqual([])
})
})
describe('assemble: warnings', () => {
it('warns about unused labels', () => {
const result = assemble('lonely: HLT', isa, model)
expect(result.ok).toBe(true)
expect(
result.diagnostics.find((d) => d.severity === 'warning')?.message,
).toContain('label "lonely" is never used')
})
})
+450
View File
@@ -0,0 +1,450 @@
import { fieldMaxValue, packField } from '../isa/encoding'
import type { Instruction, IsaDefinition, OperandSlot } from '../isa/isaModel'
import { maskOf, type MachineModel } from '../machine/machineModel'
import { matchRegister, parseNumber, registerNames } from './syntax'
export interface AsmDiagnostic {
severity: 'error' | 'warning'
message: string
/** 1-based. */
line: number
/** 1-based column of the offending token (best effort). */
column: number
}
export interface AsmSegment {
address: number
values: number[]
}
export interface SourceMapEntry {
address: number
/** 1-based source line the words at `address` came from. */
line: number
/** How many words this statement emitted. */
words: number
}
export interface AssembleResult {
/** True when there are no error diagnostics. */
ok: boolean
diagnostics: AsmDiagnostic[]
/** Sorted, non-overlapping memory segments (empty when not ok). */
segments: AsmSegment[]
/** address ↔ source line map for debugger integration */
sourceMap: SourceMapEntry[]
/** Resolved label addresses (lower-cased names). */
labels: Record<string, number>
}
// ---------------------------------------------------------------------------
interface Operand {
text: string
column: number
}
type Statement =
| {
kind: 'instruction'
instr: Instruction
operands: Operand[]
line: number
column: number
address: number
}
| {
kind: 'data'
directive: '.word' | '.byte'
values: Operand[]
line: number
address: number
}
const LABEL_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*:/
const MNEMONIC_RE = /^([A-Za-z_.][A-Za-z0-9_.]*)/
/** Split an operand list on commas, tracking each operand's column. */
function splitOperands(text: string, baseColumn: number): Operand[] {
const result: Operand[] = []
let start = 0
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === ',') {
const raw = text.slice(start, i)
const trimmedStart = start + (raw.length - raw.trimStart().length)
const trimmed = raw.trim()
result.push({ text: trimmed, column: baseColumn + trimmedStart })
start = i + 1
}
}
// A trailing/blank-only split means an empty operand (syntax error upstream)
return result
}
export function assemble(
source: string,
isa: IsaDefinition,
model: MachineModel,
): AssembleResult {
const diagnostics: AsmDiagnostic[] = []
const error = (line: number, column: number, message: string) =>
diagnostics.push({ severity: 'error', message, line, column })
const warning = (line: number, column: number, message: string) =>
diagnostics.push({ severity: 'warning', message, line, column })
const memory = model.memories.find((m) => m.name === model.programMemory)
const memSize = memory?.size ?? 65536
const wordMask = maskOf(memory?.width ?? 32)
const byMnemonic = new Map(
isa.instructions.map((i) => [i.mnemonic.toLowerCase(), i]),
)
const labels = new Map<
string,
{ address: number; line: number; used: boolean }
>()
const statements: Statement[] = []
// ---- Pass 1: parse, place, collect labels ----
let counter = 0
const lines = source.split(/\r?\n/)
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lineIndex + 1
let text = lines[lineIndex] ?? ''
const commentStart = text.indexOf(';')
if (commentStart >= 0) text = text.slice(0, commentStart)
let column = 1
const advance = (n: number) => {
text = text.slice(n)
column += n
}
const skipSpace = () => {
const trimmed = text.trimStart()
advance(text.length - trimmed.length)
}
// Labels (possibly several on one line)
skipSpace()
for (;;) {
const match = LABEL_RE.exec(text)
if (!match || match[1] === undefined) break
const name = match[1]
const key = name.toLowerCase()
const existing = labels.get(key)
if (existing) {
error(
line,
column,
`duplicate label "${name}" (first defined on line ${existing.line})`,
)
} else {
labels.set(key, { address: counter, line, used: false })
}
advance(match[0].length)
skipSpace()
}
if (text.length === 0) continue
// Directive or instruction
const mnemonicMatch = MNEMONIC_RE.exec(text)
if (!mnemonicMatch || mnemonicMatch[1] === undefined) {
error(line, column, `cannot parse "${text.trim()}"`)
continue
}
const word = mnemonicMatch[1]
const wordColumn = column
advance(word.length)
skipSpace()
const rest = text.trim()
const restColumn = column + (text.length - text.trimStart().length)
if (word.startsWith('.')) {
const directive = word.toLowerCase()
if (directive === '.org') {
const value = parseNumber(rest)
if (rest.length === 0 || value === null) {
error(line, restColumn, `.org needs a number address, got "${rest}"`)
} else if (value >= memSize) {
error(
line,
restColumn,
`.org address ${value} is outside memory "${model.programMemory}" (${memSize} words)`,
)
} else {
counter = value
}
} else if (directive === '.word' || directive === '.byte') {
if (rest.length === 0) {
error(line, restColumn, `${directive} needs at least one value`)
continue
}
const values = splitOperands(rest, restColumn)
statements.push({
kind: 'data',
directive: directive as '.word' | '.byte',
values,
line,
address: counter,
})
counter += values.length
} else {
error(
line,
wordColumn,
`unknown directive "${word}" (supported: .org, .word, .byte)`,
)
}
continue
}
const instr = byMnemonic.get(word.toLowerCase())
if (!instr) {
error(
line,
wordColumn,
`unknown instruction "${word}" - this ISA defines: ${
[...byMnemonic.values()].map((i) => i.mnemonic).join(', ') || '(none)'
}`,
)
continue
}
const operands = rest.length === 0 ? [] : splitOperands(rest, restColumn)
if (operands.some((o) => o.text.length === 0)) {
error(line, restColumn, 'empty operand (stray comma?)')
continue
}
if (operands.length !== instr.operands.length) {
error(
line,
restColumn,
`${instr.mnemonic} expects ${instr.operands.length} operand(s), got ${operands.length}`,
)
continue
}
statements.push({
kind: 'instruction',
instr,
operands,
line,
column: wordColumn,
address: counter,
})
counter += instr.words
}
// ---- Pass 2: resolve and encode ----
const resolveValue = (operand: Operand, line: number): number | null => {
let text = operand.text
if (text.startsWith('#')) text = text.slice(1).trim()
const num = parseNumber(text)
if (num !== null) return num
if (IDENT_ONLY.test(text)) {
const label = labels.get(text.toLowerCase())
if (label) {
label.used = true
return label.address
}
error(line, operand.column, `undefined label "${text}"`)
return null
}
error(line, operand.column, `cannot parse value "${operand.text}"`)
return null
}
const matchOperand = (
slot: OperandSlot,
operand: Operand,
instr: Instruction,
line: number,
): number | null => {
const text = operand.text
if (slot.kind === 'register') {
const bank = model.banks.find((b) => b.name === slot.bank)
if (!bank) {
error(
line,
operand.column,
`instruction ${instr.mnemonic}: operand "${slot.name}" references unknown bank "${slot.bank ?? ''}" - fix the ISA definition`,
)
return null
}
const index = matchRegister(text, bank)
if (index === null) {
const names = registerNames(bank)
error(
line,
operand.column,
`expected a "${bank.name}" register (${names[0]}${names.length > 1 ? `${names[names.length - 1]}` : ''}), got "${text}"`,
)
return null
}
return index
}
if (slot.kind === 'immediate') {
if (!text.startsWith('#')) {
error(
line,
operand.column,
`operand "${slot.name}" is an immediate - write it as #${text}`,
)
return null
}
return resolveValue(operand, line)
}
// address
if (text.startsWith('#')) {
error(
line,
operand.column,
`operand "${slot.name}" is an address - write it without '#'`,
)
return null
}
return resolveValue(operand, line)
}
const image = new Map<number, { value: number; line: number }>()
const sourceMap: SourceMapEntry[] = []
const emit = (address: number, values: number[], line: number): void => {
if (address < 0 || address + values.length > memSize) {
error(
line,
1,
`program does not fit: words ${address}-${address + values.length - 1} exceed memory "${model.programMemory}" (${memSize} words)`,
)
return
}
for (const [i, value] of values.entries()) {
const existing = image.get(address + i)
if (existing) {
error(
line,
1,
`address ${address + i} written twice (previously from line ${existing.line}) - check .org directives`,
)
return
}
image.set(address + i, { value, line })
}
sourceMap.push({ address, line, words: values.length })
}
for (const stmt of statements) {
if (stmt.kind === 'data') {
const values: number[] = []
let bad = false
for (const operand of stmt.values) {
const value = resolveValue(operand, stmt.line)
if (value === null) {
bad = true
continue
}
const limit = stmt.directive === '.byte' ? 255 : wordMask
if (value > limit) {
error(
stmt.line,
operand.column,
`value ${value} does not fit ${stmt.directive === '.byte' ? 'a byte' : `a ${memory?.width ?? 32}-bit word`}`,
)
bad = true
continue
}
values.push(value & wordMask)
}
if (!bad) emit(stmt.address, values, stmt.line)
continue
}
// Instruction
let words: number[] = new Array<number>(stmt.instr.words).fill(0)
let bad = false
try {
words = packField(
words,
{ word: 0, ...isa.opcodeField },
stmt.instr.opcode,
)
} catch {
error(
stmt.line,
stmt.column,
`opcode ${stmt.instr.opcode} of ${stmt.instr.mnemonic} does not fit the opcode field - fix the ISA definition`,
)
continue
}
for (const [i, slot] of stmt.instr.operands.entries()) {
const operand = stmt.operands[i]
if (!operand) continue
const value = matchOperand(slot, operand, stmt.instr, stmt.line)
if (value === null) {
bad = true
continue
}
if (value > fieldMaxValue(slot.field)) {
error(
stmt.line,
operand.column,
`value ${value} does not fit operand "${slot.name}" (${slot.field.width} bit${slot.field.width === 1 ? '' : 's'}, max ${fieldMaxValue(slot.field)})`,
)
bad = true
continue
}
try {
words = packField(words, slot.field, value)
} catch {
error(
stmt.line,
operand.column,
`operand "${slot.name}" does not fit instruction ${stmt.instr.mnemonic} - fix the ISA encoding`,
)
bad = true
}
}
if (!bad) emit(stmt.address, words, stmt.line)
}
// Unused labels are worth a nudge
for (const [name, label] of labels) {
if (!label.used) {
warning(label.line, 1, `label "${name}" is never used`)
}
}
const ok = !diagnostics.some((d) => d.severity === 'error')
// Coalesce the flat image into sorted contiguous segments
const segments: AsmSegment[] = []
if (ok) {
const addresses = [...image.keys()].sort((a, b) => a - b)
for (const addr of addresses) {
const cell = image.get(addr)
if (cell === undefined) continue
const last = segments[segments.length - 1]
if (last && last.address + last.values.length === addr) {
last.values.push(cell.value)
} else {
segments.push({ address: addr, values: [cell.value] })
}
}
}
sourceMap.sort((a, b) => a.address - b.address)
return {
ok,
diagnostics,
segments: ok ? segments : [],
sourceMap: ok ? sourceMap : [],
labels: Object.fromEntries(
[...labels.entries()].map(([name, l]) => [name, l.address]),
),
}
}
const IDENT_ONLY = /^[A-Za-z_][A-Za-z0-9_]*$/
+91
View File
@@ -0,0 +1,91 @@
import {
RangeSet,
StateEffect,
StateField,
type Extension,
} from '@codemirror/state'
import {
Decoration,
EditorView,
gutter,
GutterMarker,
type DecorationSet,
} from '@codemirror/view'
// ---- breakpoint gutter ----
const marker = new (class extends GutterMarker {
override toDOM() {
const dot = document.createElement('span')
dot.className = 'cm-breakpoint-dot'
dot.textContent = '●'
return dot
}
})()
/** Replace the full set of breakpoint lines (1-based). */
export const setBreakpointLinesEffect = StateEffect.define<number[]>()
const breakpointField = StateField.define<RangeSet<GutterMarker>>({
create: () => RangeSet.empty,
update(set, tr) {
let next = set.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setBreakpointLinesEffect)) {
const ranges = effect.value
.filter((line) => line >= 1 && line <= tr.state.doc.lines)
.map((line) => marker.range(tr.state.doc.line(line).from))
next = RangeSet.of(ranges, true)
}
}
return next
},
})
export function breakpointGutter(onToggle: (line: number) => void): Extension {
return [
breakpointField,
gutter({
class: 'cm-breakpoint-gutter',
markers: (view) => view.state.field(breakpointField),
initialSpacer: () => marker,
domEventHandlers: {
mousedown(view, block) {
onToggle(view.state.doc.lineAt(block.from).number)
return true
},
},
}),
]
}
// ---- current-instruction line highlight ----
/** Set (1-based) or clear the highlighted execution line. */
export const setExecLineEffect = StateEffect.define<number | null>()
const execLineDecoration = Decoration.line({ class: 'cm-exec-line' })
export const execLineField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
let next = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setExecLineEffect)) {
if (
effect.value === null ||
effect.value < 1 ||
effect.value > tr.state.doc.lines
) {
next = Decoration.none
} else {
next = Decoration.set([
execLineDecoration.range(tr.state.doc.line(effect.value).from),
])
}
}
}
return next
},
provide: (field) => EditorView.decorations.from(field),
})
+31
View File
@@ -0,0 +1,31 @@
import type { EditorView } from '@codemirror/view'
import { setExecLineEffect } from './debugExtensions'
let liveView: EditorView | null = null
export function registerEditorView(view: EditorView): void {
liveView = view
}
export function unregisterEditorView(view: EditorView): void {
if (liveView === view) liveView = null
}
/** Move the cursor to a 1-based line, scroll it into view, and focus. */
export function goToLine(line: number): void {
const view = liveView
if (!view) return
const clamped = Math.max(1, Math.min(line, view.state.doc.lines))
const pos = view.state.doc.line(clamped).from
view.dispatch({
selection: { anchor: pos },
scrollIntoView: true,
})
view.focus()
}
/** Highlight (or clear) the line whose instruction the pc points at. */
export function setExecLine(line: number | null): void {
liveView?.dispatch({ effects: setExecLineEffect.of(line) })
}
+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
activeProgramOf,
defaultPrograms,
useProgramStore,
} from './programStore'
beforeEach(() => {
useProgramStore.getState().resetPrograms()
})
const state = () => useProgramStore.getState()
describe('program management', () => {
it('starts with a default "main" program, active', () => {
expect(state().programs).toHaveLength(1)
expect(state().programs[0]?.name).toBe('main')
expect(activeProgramOf(state())?.name).toBe('main')
})
it('adds programs with unique names and selects them', () => {
state().addProgram()
state().addProgram()
const names = state().programs.map((p) => p.name)
expect(new Set(names).size).toBe(3)
expect(activeProgramOf(state())?.name).toBe(names[2])
})
it('renames (trimmed, non-empty only)', () => {
const id = state().programs[0]?.id ?? ''
state().renameProgram(id, ' boot ')
expect(state().programs[0]?.name).toBe('boot')
state().renameProgram(id, ' ')
expect(state().programs[0]?.name).toBe('boot')
})
it('updates source per program', () => {
state().addProgram()
const [a, b] = state().programs
if (!a || !b) throw new Error('setup')
state().updateSource(a.id, 'HLT')
expect(state().programs[0]?.source).toBe('HLT')
expect(state().programs[1]?.source).toBe(b.source)
})
it('removing the active program falls back to the first remaining', () => {
state().addProgram()
const second = state().programs[1]
if (!second) throw new Error('setup')
expect(state().activeId).toBe(second.id)
state().removeProgram(second.id)
expect(activeProgramOf(state())?.name).toBe('main')
})
it('loadPrograms replaces everything and clears results', () => {
state().setResult(state().programs[0]?.id ?? '', {
ok: true,
diagnostics: [],
segments: [],
sourceMap: [],
labels: {},
})
const programs = defaultPrograms()
state().loadPrograms(programs)
expect(state().programs).toBe(programs)
expect(state().result).toBeNull()
expect(state().activeId).toBe(programs[0]?.id)
})
it('handles an empty program list (imported doc with no programs)', () => {
state().loadPrograms([])
expect(activeProgramOf(state())).toBeNull()
})
})
+104
View File
@@ -0,0 +1,104 @@
import { create } from 'zustand'
import type { AssembleResult } from './assembler'
export interface Program {
id: string
name: string
source: string
}
export const STARTER_SOURCE = `; Write assembly for your CPU here.
; Syntax: docs/assembly-syntax.md - labels, #immediates, .org/.word/.byte
`
export function defaultPrograms(): Program[] {
return [{ id: crypto.randomUUID(), name: 'main', source: STARTER_SOURCE }]
}
export interface ProgramState {
programs: Program[]
/** Selected program (UI state, not persisted). */
activeId: string | null
/** Last assemble outcome shown in the Output tab (transient). */
result: { programId: string; result: AssembleResult } | null
setActive: (id: string) => void
addProgram: () => void
renameProgram: (id: string, name: string) => void
updateSource: (id: string, source: string) => void
removeProgram: (id: string) => void
setResult: (programId: string, result: AssembleResult) => void
loadPrograms: (programs: Program[]) => void
resetPrograms: () => void
}
export const useProgramStore = create<ProgramState>()((set, get) => ({
programs: defaultPrograms(),
activeId: null,
result: null,
setActive: (id) => set({ activeId: id }),
addProgram: () => {
const { programs } = get()
const names = new Set(programs.map((p) => p.name.toLowerCase()))
let n = programs.length + 1
let name = `program${n}`
while (names.has(name.toLowerCase())) name = `program${++n}`
const program: Program = {
id: crypto.randomUUID(),
name,
source: STARTER_SOURCE,
}
set({ programs: [...programs, program], activeId: program.id })
},
renameProgram: (id, name) => {
const trimmed = name.trim()
if (trimmed.length === 0) return
set({
programs: get().programs.map((p) =>
p.id === id ? { ...p, name: trimmed } : p,
),
})
},
updateSource: (id, source) => {
set({
programs: get().programs.map((p) => (p.id === id ? { ...p, source } : p)),
})
},
removeProgram: (id) => {
const { programs, activeId, result } = get()
const remaining = programs.filter((p) => p.id !== id)
set({
programs: remaining,
activeId: activeId === id ? (remaining[0]?.id ?? null) : activeId,
result: result?.programId === id ? null : result,
})
},
setResult: (programId, result) => set({ result: { programId, result } }),
loadPrograms: (programs) =>
set({
programs,
activeId: programs[0]?.id ?? null,
result: null,
}),
resetPrograms: () => {
const programs = defaultPrograms()
set({ programs, activeId: programs[0]?.id ?? null, result: null })
},
}))
/** The active program, resolved (helper for components). */
export function activeProgramOf(state: ProgramState): Program | null {
return (
state.programs.find((p) => p.id === state.activeId) ??
state.programs[0] ??
null
)
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { matchRegister, parseNumber, registerNames } from './syntax'
describe('parseNumber', () => {
it('parses decimal, hex (0x and $), and binary', () => {
expect(parseNumber('42')).toBe(42)
expect(parseNumber('0x2A')).toBe(42)
expect(parseNumber('0X2a')).toBe(42)
expect(parseNumber('$2A')).toBe(42)
expect(parseNumber('0b101010')).toBe(42)
})
it('rejects non-numbers', () => {
expect(parseNumber('')).toBeNull()
expect(parseNumber('x2A')).toBeNull()
expect(parseNumber('12abc')).toBeNull()
expect(parseNumber('-5')).toBeNull()
expect(parseNumber('0b102')).toBeNull()
expect(parseNumber('label')).toBeNull()
})
})
describe('registerNames / matchRegister', () => {
const rBank = { name: 'R', width: 8, count: 4 }
const acc = { name: 'ACC', width: 8, count: 1 }
it('names indexed banks R0..Rn and single registers by bare name', () => {
expect(registerNames(rBank)).toEqual(['R0', 'R1', 'R2', 'R3'])
expect(registerNames(acc)).toEqual(['ACC'])
})
it('matches case-insensitively and validates the index range', () => {
expect(matchRegister('R2', rBank)).toBe(2)
expect(matchRegister('r0', rBank)).toBe(0)
expect(matchRegister('R4', rBank)).toBeNull()
expect(matchRegister('R', rBank)).toBeNull()
expect(matchRegister('Rx', rBank)).toBeNull()
expect(matchRegister('acc', acc)).toBe(0)
expect(matchRegister('ACC0', acc)).toBeNull()
})
})
+40
View File
@@ -0,0 +1,40 @@
import type { RegisterBank } from '../machine/machineModel'
export const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
/** Parse a number literal; null when the token is not a number. */
export function parseNumber(token: string): number | null {
let match = /^(\d+)$/.exec(token)
if (match?.[1] !== undefined) return Number.parseInt(match[1], 10)
match = /^0[xX]([0-9a-fA-F]+)$/.exec(token)
if (match?.[1] !== undefined) return Number.parseInt(match[1], 16)
match = /^\$([0-9a-fA-F]+)$/.exec(token)
if (match?.[1] !== undefined) return Number.parseInt(match[1], 16)
match = /^0[bB]([01]+)$/.exec(token)
if (match?.[1] !== undefined) return Number.parseInt(match[1], 2)
return null
}
/** All assembly-visible register names of a bank (single register: bare name). */
export function registerNames(bank: RegisterBank): string[] {
if (bank.count === 1) return [bank.name]
return Array.from({ length: bank.count }, (_, i) => `${bank.name}${i}`)
}
/**
* Match a token as a register of `bank`; returns the register index or null.
* Case-insensitive.
*/
export function matchRegister(
token: string,
bank: RegisterBank,
): number | null {
const t = token.toLowerCase()
const name = bank.name.toLowerCase()
if (bank.count === 1) return t === name ? 0 : null
if (!t.startsWith(name)) return null
const rest = t.slice(name.length)
if (!/^\d+$/.test(rest)) return null
const index = Number.parseInt(rest, 10)
return index < bank.count ? index : null
}
+98
View File
@@ -0,0 +1,98 @@
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { memo } from 'react'
import { useGraphStore } from './graphStore'
import { getNodeTypeDef, portsOf, type PortDef, type WmNode } from './nodeTypes'
/**
* Generic renderer for every CPU block type; entirely driven by the node-type
* registry. Inputs dock on the left, outputs on the right; data ports are
* round, control ports are diamonds.
*/
function PortRow({ port }: { port: PortDef }) {
const isIn = port.direction === 'in'
return (
<div className={isIn ? 'port-row port-row-in' : 'port-row port-row-out'}>
<Handle
id={port.id}
type={isIn ? 'target' : 'source'}
position={isIn ? Position.Left : Position.Right}
className={`wm-handle wm-handle-${port.kind}`}
title={`${port.label} (${port.kind})`}
/>
<span className="port-label">{port.label}</span>
</div>
)
}
export const BlockNode = memo(function BlockNode({
type,
data,
selected,
}: NodeProps<WmNode>) {
const def = getNodeTypeDef(type)
if (!def) return null
const inputs = portsOf(def, 'in')
const outputs = portsOf(def, 'out')
const summary = def.summary?.(data.params)
return (
<div
className={`block-node cat-${def.category}${selected ? ' block-node-selected' : ''}`}
data-testid={`node-${def.type}`}
>
<div className="block-node-header">
<span className="block-node-type">{def.label}</span>
<span className="block-node-name">{data.name}</span>
</div>
<div className="block-node-body">
<div className="block-node-ports block-node-inputs">
{inputs.map((port) => (
<PortRow key={port.id} port={port} />
))}
</div>
<div className="block-node-mid">
{summary ? (
<span className="block-node-summary">{summary}</span>
) : null}
</div>
<div className="block-node-ports block-node-outputs">
{outputs.map((port) => (
<PortRow key={port.id} port={port} />
))}
</div>
</div>
{data.doc ? (
<div className="block-node-doc" title={data.doc}>
{data.doc}
</div>
) : null}
</div>
)
})
/** Sticky-note comment. Text is edited inline (textarea) or in the inspector. */
export const CommentNode = memo(function CommentNode({
id,
data,
selected,
}: NodeProps<WmNode>) {
const updateNodeData = useGraphStore((s) => s.updateNodeData)
return (
<div
className={`comment-node${selected ? ' block-node-selected' : ''}`}
data-testid="node-comment"
>
<textarea
className="comment-node-text nodrag"
value={String(data.params['text'] ?? '')}
onChange={(e) =>
updateNodeData(id, { params: { text: e.target.value } })
}
aria-label="Comment text"
rows={3}
/>
</div>
)
})
+147
View File
@@ -0,0 +1,147 @@
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
useReactFlow,
type IsValidConnection,
type NodeTypes,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCallback, useEffect, type DragEvent } from 'react'
import { useAppStore } from '../app/store'
import { checkpoint } from '../model/history'
import { Button } from '../ui/Button'
import './editor.css'
import { BlockNode, CommentNode } from './BlockNode'
import { checkConnection } from './graphRules'
import { useGraphStore } from './graphStore'
import {
CATEGORY_COLORS,
getNodeTypeDef,
NODE_TYPE_DEFS,
type WmEdge,
type WmNode,
} from './nodeTypes'
/** MIME type used for toolbox -> canvas drag-and-drop. */
export const DND_MIME = 'application/webmetal-node-type'
// Stable nodeTypes map: every registry type renders through BlockNode except
// the comment sticky note.
const nodeTypes: NodeTypes = Object.fromEntries(
NODE_TYPE_DEFS.map((def) => [
def.type,
def.type === 'comment' ? CommentNode : BlockNode,
]),
)
export function GraphCanvas() {
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const onNodesChange = useGraphStore((s) => s.onNodesChange)
const onEdgesChange = useGraphStore((s) => s.onEdgesChange)
const onConnect = useGraphStore((s) => s.onConnect)
const addNode = useGraphStore((s) => s.addNode)
const storeViewport = useGraphStore((s) => s.storeViewport)
const revision = useGraphStore((s) => s.revision)
const theme = useAppStore((s) => s.theme)
const setExamplesOpen = useAppStore((s) => s.setExamplesOpen)
const setHelpOpen = useAppStore((s) => s.setHelpOpen)
const { screenToFlowPosition, setViewport } = useReactFlow()
// When the graph is replaced wholesale (import/restore/new project), apply
// the document's viewport so the layout appears exactly as it was saved.
useEffect(() => {
if (revision > 0) {
void setViewport(useGraphStore.getState().viewport)
}
}, [revision, setViewport])
const isValidConnection: IsValidConnection<WmEdge> = useCallback((conn) => {
const { nodes, edges } = useGraphStore.getState()
return checkConnection(nodes, edges, {
source: conn.source,
target: conn.target,
sourceHandle: conn.sourceHandle ?? null,
targetHandle: conn.targetHandle ?? null,
}).ok
}, [])
const onDragOver = useCallback((event: DragEvent) => {
if (event.dataTransfer.types.includes(DND_MIME)) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}, [])
const onDrop = useCallback(
(event: DragEvent) => {
const type = event.dataTransfer.getData(DND_MIME)
if (!getNodeTypeDef(type)) return
event.preventDefault()
addNode(
type,
screenToFlowPosition({ x: event.clientX, y: event.clientY }),
)
},
[addNode, screenToFlowPosition],
)
return (
<div className="canvas-area" data-testid="canvas-area">
<ReactFlow<WmNode, WmEdge>
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={() => checkpoint()}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
colorMode={theme}
defaultViewport={useGraphStore.getState().viewport}
onViewportChange={storeViewport}
onDragOver={onDragOver}
onDrop={onDrop}
snapToGrid
snapGrid={[16, 16]}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
minZoom={0.2}
maxZoom={2.5}
fitView={false}
proOptions={{ hideAttribution: false }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls />
<MiniMap
pannable
zoomable
nodeColor={(node) =>
CATEGORY_COLORS[getNodeTypeDef(node.type)?.category ?? 'annotation']
}
/>
</ReactFlow>
{nodes.length === 0 ? (
<div className="canvas-empty" data-testid="canvas-empty">
<div className="canvas-empty-card">
<h3>An empty canvas</h3>
<p>
Drag blocks from the Toolbox to design a CPU - or start from a
complete, documented machine.
</p>
<div className="canvas-empty-actions">
<Button variant="solid" onClick={() => setExamplesOpen(true)}>
Open an example
</Button>
<Button onClick={() => setHelpOpen(true)}>Getting started</Button>
</div>
</div>
</div>
) : null}
</div>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { useAppStore } from '../app/store'
import { Button } from '../ui/Button'
import { useGraphStore } from './graphStore'
import { getNodeTypeDef, type FieldDef, type WmNode } from './nodeTypes'
/**
* Inspector content: a data-driven property form for the selected node
* (name, registry fields, documentation), or a summary for edges and
* multi-selections.
*/
function Field({ node, field }: { node: WmNode; field: FieldDef }) {
const updateNodeData = useGraphStore((s) => s.updateNodeData)
const value = node.data.params[field.key] ?? ''
const id = `field-${node.id}-${field.key}`
const commit = (raw: string) => {
let next: string | number = raw
if (field.kind === 'number') {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return
next = Math.round(parsed)
if (field.min !== undefined) next = Math.max(field.min, next)
if (field.max !== undefined) next = Math.min(field.max, next)
}
updateNodeData(node.id, { params: { [field.key]: next } })
}
return (
<label className="inspector-field" htmlFor={id}>
<span className="inspector-field-label">{field.label}</span>
{field.kind === 'textarea' ? (
<textarea
id={id}
value={String(value)}
rows={4}
onChange={(e) => commit(e.target.value)}
/>
) : (
<input
id={id}
type={field.kind === 'number' ? 'number' : 'text'}
value={value}
min={field.min}
max={field.max}
onChange={(e) => commit(e.target.value)}
/>
)}
{field.help ? (
<span className="inspector-field-help">{field.help}</span>
) : null}
</label>
)
}
function NodeForm({ node }: { node: WmNode }) {
const updateNodeData = useGraphStore((s) => s.updateNodeData)
const def = getNodeTypeDef(node.type)
if (!def) return null
return (
<div className="inspector-form" data-testid="inspector-node-form">
<div className={`inspector-type-badge cat-${def.category}`}>
{def.label}
</div>
<p className="inspector-description">{def.description}</p>
<label className="inspector-field" htmlFor={`name-${node.id}`}>
<span className="inspector-field-label">Name</span>
<input
id={`name-${node.id}`}
data-testid="inspector-name"
type="text"
value={node.data.name}
onChange={(e) => updateNodeData(node.id, { name: e.target.value })}
/>
</label>
{def.fields.map((field) => (
<Field key={field.key} node={node} field={field} />
))}
{def.type !== 'comment' ? (
<label className="inspector-field" htmlFor={`doc-${node.id}`}>
<span className="inspector-field-label">Documentation</span>
<textarea
id={`doc-${node.id}`}
value={node.data.doc}
rows={4}
placeholder="What does this block do in your design?"
onChange={(e) => updateNodeData(node.id, { doc: e.target.value })}
/>
</label>
) : null}
</div>
)
}
/** Shown when nothing is selected: the project's own metadata. */
function ProjectForm() {
const projectMeta = useAppStore((s) => s.projectMeta)
const setProjectMeta = useAppStore((s) => s.setProjectMeta)
return (
<div className="inspector" data-testid="inspector-project-form">
<div className="inspector-form">
<div className="inspector-type-badge cat-wire">Project</div>
<label className="inspector-field" htmlFor="project-name">
<span className="inspector-field-label">Name</span>
<input
id="project-name"
data-testid="project-name-input"
type="text"
value={projectMeta.name}
onChange={(e) => setProjectMeta({ name: e.target.value })}
/>
</label>
<label className="inspector-field" htmlFor="project-author">
<span className="inspector-field-label">Author</span>
<input
id="project-author"
data-testid="project-author-input"
type="text"
value={projectMeta.author}
onChange={(e) => setProjectMeta({ author: e.target.value })}
/>
</label>
<label className="inspector-field" htmlFor="project-description">
<span className="inspector-field-label">Description</span>
<textarea
id="project-description"
value={projectMeta.description}
rows={5}
placeholder="What is this CPU design about?"
onChange={(e) => setProjectMeta({ description: e.target.value })}
/>
</label>
<p className="inspector-description">
Select a block or wire on the canvas to edit it instead.
</p>
</div>
</div>
)
}
export function Inspector() {
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const deleteSelection = useGraphStore((s) => s.deleteSelection)
const selectedNodes = nodes.filter((n) => n.selected)
const selectedEdges = edges.filter((e) => e.selected)
const total = selectedNodes.length + selectedEdges.length
if (total === 0) {
return <ProjectForm />
}
if (selectedNodes.length === 1 && selectedEdges.length === 0) {
const node = selectedNodes[0]
return (
<div className="inspector">
{node ? <NodeForm node={node} /> : null}
<Button className="inspector-delete" onClick={deleteSelection}>
Delete block
</Button>
</div>
)
}
if (selectedEdges.length === 1 && selectedNodes.length === 0) {
const edge = selectedEdges[0]
const name = (id: string) =>
nodes.find((n) => n.id === id)?.data.name ?? '?'
return (
<div className="inspector">
<div className="inspector-form">
<div className="inspector-type-badge cat-wire">
{edge?.data?.kind === 'control' ? 'Control wire' : 'Data bus wire'}
</div>
<p className="inspector-description">
{edge ? `${name(edge.source)} -> ${name(edge.target)}` : ''}
</p>
</div>
<Button className="inspector-delete" onClick={deleteSelection}>
Delete wire
</Button>
</div>
)
}
return (
<div className="inspector">
<div className="inspector-form">
<p className="inspector-description">
{selectedNodes.length} block(s) and {selectedEdges.length} wire(s)
selected.
</p>
</div>
<Button className="inspector-delete" onClick={deleteSelection}>
Delete selection
</Button>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useReactFlow } from '@xyflow/react'
import type { DragEvent } from 'react'
import { DND_MIME } from './GraphCanvas'
import { useGraphStore } from './graphStore'
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
NODE_TYPE_DEFS,
type NodeTypeDef,
} from './nodeTypes'
/**
* The block palette. Blocks are dragged onto the canvas (HTML5 dnd) or
* clicked to add at the canvas center - the keyboard/screen-reader path.
*/
function ToolboxItem({ def }: { def: NodeTypeDef }) {
const addNode = useGraphStore((s) => s.addNode)
const { screenToFlowPosition } = useReactFlow()
const onDragStart = (event: DragEvent) => {
event.dataTransfer.setData(DND_MIME, def.type)
event.dataTransfer.effectAllowed = 'copy'
}
const addAtCenter = () => {
const pane = document.querySelector('.react-flow')
const rect = pane?.getBoundingClientRect()
const center = rect
? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
: { x: 300, y: 200 }
// Nudge so repeated clicks don't stack exactly on top of each other.
const n = useGraphStore.getState().nodes.length
const pos = screenToFlowPosition(center)
addNode(def.type, { x: pos.x + (n % 5) * 24, y: pos.y + (n % 5) * 24 })
}
return (
<button
type="button"
className={`toolbox-item cat-${def.category}`}
draggable
onDragStart={onDragStart}
onClick={addAtCenter}
title={`${def.description}\n\nDrag onto the canvas, or click to add.`}
data-node-type={def.type}
>
<span className="toolbox-item-swatch" aria-hidden="true" />
{def.label}
</button>
)
}
export function Toolbox() {
return (
<div className="toolbox">
{CATEGORY_ORDER.map((category) => {
const defs = NODE_TYPE_DEFS.filter((d) => d.category === category)
if (defs.length === 0) return null
return (
<section key={category} className="toolbox-group">
<h3 className="toolbox-group-title">{CATEGORY_LABELS[category]}</h3>
{defs.map((def) => (
<ToolboxItem key={def.type} def={def} />
))}
</section>
)
})}
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
:root {
--cat-storage: #4c97ff;
--cat-compute: #59c059;
--cat-memory: #ff8c1a;
--cat-control: #9966ff;
--cat-annotation: #e6b800;
--edge-data: #7a8699;
--note-bg: #fff6c8;
--note-text: #4a3f00;
}
:root[data-theme='dark'] {
--edge-data: #8b97ab;
--note-bg: #4a4325;
--note-text: #f4ecc0;
}
.cat-storage {
--cat: var(--cat-storage);
}
.cat-compute {
--cat: var(--cat-compute);
}
.cat-memory {
--cat: var(--cat-memory);
}
.cat-control {
--cat: var(--cat-control);
}
.cat-annotation {
--cat: var(--cat-annotation);
}
.cat-wire {
--cat: var(--edge-data);
}
/* --- Canvas frame --- */
.canvas-area {
flex: 1;
min-height: 0;
position: relative;
}
/* --- Block nodes --- */
.block-node {
min-width: 168px;
border: 2px solid var(--cat);
border-radius: 10px;
background: var(--bg-panel);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
font-size: 12px;
}
.block-node-selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.block-node-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 5px 10px;
background: var(--cat);
color: #fff;
border-radius: 6px 6px 0 0;
}
.block-node-type {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.9;
white-space: nowrap;
}
.block-node-name {
font-family: var(--mono);
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.block-node-body {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
}
.block-node-ports {
display: flex;
flex-direction: column;
gap: 3px;
}
.block-node-mid {
display: grid;
place-items: center;
flex: 1;
}
.block-node-summary {
font-family: var(--mono);
font-size: 11px;
color: var(--text);
white-space: nowrap;
}
.port-row {
position: relative;
display: flex;
align-items: center;
height: 18px;
}
.port-row-in {
padding-left: 10px;
}
.port-row-out {
justify-content: flex-end;
padding-right: 10px;
}
.port-label {
font-size: 10.5px;
color: var(--text);
white-space: nowrap;
}
.block-node-doc {
padding: 4px 10px 6px;
border-top: 1px dashed var(--border);
font-size: 10.5px;
font-style: italic;
color: var(--text);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Handles: data = circle, control = diamond --- */
.wm-handle {
width: 10px;
height: 10px;
border: 2px solid var(--bg-panel);
}
.wm-handle-data {
background: var(--edge-data);
border-radius: 50%;
}
.wm-handle-control {
background: var(--cat-control);
border-radius: 2px;
transform: rotate(45deg);
}
/* Keep RF's vertical centering while adding the diamond rotation. */
.react-flow__handle.wm-handle-control {
transform: translate(0, -50%) rotate(45deg);
}
/* --- Edges --- */
.react-flow__edge.edge-data path.react-flow__edge-path {
stroke: var(--edge-data);
stroke-width: 2.25;
}
.react-flow__edge.edge-control path.react-flow__edge-path {
stroke: var(--cat-control);
stroke-width: 1.75;
stroke-dasharray: 7 4;
}
.react-flow__edge.selected path.react-flow__edge-path {
stroke: var(--accent);
}
/* --- Comment (sticky note) --- */
.comment-node {
background: var(--note-bg);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.14);
padding: 8px;
min-width: 180px;
}
.comment-node-text {
width: 100%;
min-height: 56px;
border: none;
background: transparent;
resize: both;
font: 12.5px/1.45 var(--sans);
color: var(--note-text);
outline: none;
}
/* --- Toolbox --- */
.toolbox {
display: flex;
flex-direction: column;
gap: 14px;
}
.toolbox-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.toolbox-group-title {
margin: 0 0 2px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text);
}
.toolbox-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
font: 13px var(--sans);
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: grab;
text-align: left;
}
.toolbox-item:hover {
border-color: var(--cat);
background: var(--hover-bg);
}
.toolbox-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.toolbox-item-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--cat);
flex: none;
}
/* --- Inspector --- */
.inspector {
display: flex;
flex-direction: column;
gap: 14px;
}
.inspector-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.inspector-type-badge {
align-self: flex-start;
padding: 3px 10px;
border-radius: 6px;
background: var(--cat);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inspector-description {
font-size: 12.5px;
color: var(--text);
}
.inspector-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.inspector-field-label {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.inspector-field input,
.inspector-field textarea {
font: 13px var(--sans);
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
.inspector-field input:focus-visible,
.inspector-field textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
.inspector-field-help {
font-size: 11px;
color: var(--text);
opacity: 0.8;
}
.inspector-delete {
align-self: flex-start;
}
/* --- React Flow chrome in our themes --- */
.react-flow__minimap {
border-radius: 8px;
overflow: hidden;
}
.react-flow__controls {
border-radius: 8px;
overflow: hidden;
box-shadow: var(--shadow);
}
/* --- Empty-canvas hint (overlay; must not block canvas drag & drop) --- */
.canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
z-index: 4;
}
.canvas-empty-card {
text-align: center;
max-width: 26rem;
padding: 24px 28px;
border: 1px dashed var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
}
.canvas-empty-card h3 {
margin: 0 0 6px;
font-size: 16px;
}
.canvas-empty-card p {
margin: 0 0 14px;
font-size: 13.5px;
color: var(--text-muted);
}
.canvas-empty-actions {
display: flex;
justify-content: center;
gap: 8px;
pointer-events: auto;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { checkConnection } from './graphRules'
import type { WmEdge, WmNode } from './nodeTypes'
function node(id: string, type: string): WmNode {
return {
id,
type,
position: { x: 0, y: 0 },
data: { name: id, doc: '', params: {} },
}
}
const nodes: WmNode[] = [
node('r1', 'register'),
node('r2', 'register'),
node('alu1', 'alu'),
node('ctl1', 'control'),
]
const conn = (
source: string,
sourceHandle: string,
target: string,
targetHandle: string,
) => ({ source, sourceHandle, target, targetHandle })
describe('checkConnection', () => {
it('accepts data -> data', () => {
const verdict = checkConnection(nodes, [], conn('r1', 'out', 'alu1', 'a'))
expect(verdict).toEqual({ ok: true, kind: 'data' })
})
it('accepts control -> control', () => {
const verdict = checkConnection(
nodes,
[],
conn('ctl1', 'signals', 'r1', 'load'),
)
expect(verdict).toEqual({ ok: true, kind: 'control' })
})
it('rejects data -> control and control -> data', () => {
expect(checkConnection(nodes, [], conn('r1', 'out', 'r2', 'load')).ok).toBe(
false,
)
expect(
checkConnection(nodes, [], conn('ctl1', 'signals', 'alu1', 'a')).ok,
).toBe(false)
})
it('rejects self-connections', () => {
expect(checkConnection(nodes, [], conn('r1', 'out', 'r1', 'in')).ok).toBe(
false,
)
})
it('rejects unknown ports and nodes', () => {
expect(checkConnection(nodes, [], conn('r1', 'nope', 'r2', 'in')).ok).toBe(
false,
)
expect(
checkConnection(nodes, [], conn('ghost', 'out', 'r2', 'in')).ok,
).toBe(false)
})
it('rejects exact duplicate wires but allows fan-out', () => {
const existing: WmEdge[] = [
{
id: 'e1',
source: 'r1',
sourceHandle: 'out',
target: 'alu1',
targetHandle: 'a',
data: { kind: 'data' },
},
]
expect(
checkConnection(nodes, existing, conn('r1', 'out', 'alu1', 'a')).ok,
).toBe(false)
// Same source port to a different input is fine (bus fan-out).
expect(
checkConnection(nodes, existing, conn('r1', 'out', 'alu1', 'b')).ok,
).toBe(true)
})
})
+60
View File
@@ -0,0 +1,60 @@
import type { Connection } from '@xyflow/react'
import {
getNodeTypeDef,
type PortKind,
type WmEdge,
type WmNode,
} from './nodeTypes'
export interface ConnectionVerdict {
ok: boolean
/** Human-readable reason when not ok (surfaced later in diagnostics). */
reason?: string
/** The wire kind when ok. */
kind?: PortKind
}
/** Look up the port definition behind one end of a connection. */
function findPort(
nodes: WmNode[],
nodeId: string | null,
handleId: string | null,
) {
const node = nodes.find((n) => n.id === nodeId)
const def = getNodeTypeDef(node?.type)
return def?.ports.find((p) => p.id === handleId)
}
export function checkConnection(
nodes: WmNode[],
edges: WmEdge[],
conn: Connection,
): ConnectionVerdict {
if (!conn.source || !conn.target) return { ok: false, reason: 'incomplete' }
if (conn.source === conn.target) {
return { ok: false, reason: 'a block cannot connect to itself' }
}
const from = findPort(nodes, conn.source, conn.sourceHandle)
const to = findPort(nodes, conn.target, conn.targetHandle)
if (!from || !to) return { ok: false, reason: 'unknown port' }
if (from.kind !== to.kind) {
return {
ok: false,
reason: `cannot wire a ${from.kind} port to a ${to.kind} port`,
}
}
const duplicate = edges.some(
(e) =>
e.source === conn.source &&
e.target === conn.target &&
e.sourceHandle === conn.sourceHandle &&
e.targetHandle === conn.targetHandle,
)
if (duplicate) return { ok: false, reason: 'wire already exists' }
return { ok: true, kind: from.kind }
}
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { nextNodeName, useGraphStore } from './graphStore'
beforeEach(() => {
useGraphStore.setState({ nodes: [], edges: [] })
})
const add = (type: string) =>
useGraphStore.getState().addNode(type, { x: 0, y: 0 })
describe('addNode', () => {
it('adds a node with defaults from the registry and selects it', () => {
add('register')
const [node] = useGraphStore.getState().nodes
expect(node?.type).toBe('register')
expect(node?.data.name).toBe('REG1')
expect(node?.data.params['width']).toBe(8)
expect(node?.selected).toBe(true)
})
it('auto-increments names per type', () => {
add('register')
add('register')
add('alu')
const names = useGraphStore.getState().nodes.map((n) => n.data.name)
expect(names).toEqual(['REG1', 'REG2', 'ALU1'])
})
it('ignores unknown types', () => {
useGraphStore.getState().addNode('bogus', { x: 0, y: 0 })
expect(useGraphStore.getState().nodes).toHaveLength(0)
})
})
describe('nextNodeName', () => {
it('fills after the highest existing suffix', () => {
add('register')
add('register')
const nodes = useGraphStore.getState().nodes
expect(nextNodeName(nodes, 'register')).toBe('REG3')
expect(nextNodeName(nodes, 'memory')).toBe('MEM1')
})
})
describe('onConnect', () => {
it('creates a typed edge for valid connections only', () => {
add('register')
add('alu')
const [reg, alu] = useGraphStore.getState().nodes
if (!reg || !alu) throw new Error('setup failed')
// Invalid: data out -> control in
useGraphStore.getState().onConnect({
source: reg.id,
sourceHandle: 'out',
target: alu.id,
targetHandle: 'op',
})
expect(useGraphStore.getState().edges).toHaveLength(0)
// Valid: data out -> data in
useGraphStore.getState().onConnect({
source: reg.id,
sourceHandle: 'out',
target: alu.id,
targetHandle: 'a',
})
const [edge] = useGraphStore.getState().edges
expect(edge?.data?.kind).toBe('data')
expect(edge?.className).toBe('edge-data')
})
})
describe('updateNodeData', () => {
it('merges name/doc and params separately', () => {
add('register')
const id = useGraphStore.getState().nodes[0]?.id ?? ''
useGraphStore.getState().updateNodeData(id, { name: 'ACC' })
useGraphStore.getState().updateNodeData(id, { params: { width: 16 } })
const node = useGraphStore.getState().nodes[0]
expect(node?.data.name).toBe('ACC')
expect(node?.data.params).toEqual({ width: 16 })
})
})
describe('deleteSelection', () => {
it('removes selected nodes and their edges', () => {
add('register')
add('alu')
const [reg, alu] = useGraphStore.getState().nodes
if (!reg || !alu) throw new Error('setup failed')
useGraphStore.getState().onConnect({
source: reg.id,
sourceHandle: 'out',
target: alu.id,
targetHandle: 'a',
})
// addNode leaves only the ALU selected.
useGraphStore.getState().deleteSelection()
const { nodes, edges } = useGraphStore.getState()
expect(nodes.map((n) => n.data.name)).toEqual(['REG1'])
expect(edges).toHaveLength(0)
})
})
+213
View File
@@ -0,0 +1,213 @@
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
type Connection,
type EdgeChange,
type NodeChange,
type Viewport,
type XYPosition,
} from '@xyflow/react'
import { create } from 'zustand'
import { checkpoint } from '../model/history'
import { checkConnection } from './graphRules'
import {
getNodeTypeDef,
type WmEdge,
type WmNode,
type WmNodeData,
} from './nodeTypes'
/** Next free auto-name for a node type: REG1, REG2, … (import-safe: derived). */
export function nextNodeName(nodes: WmNode[], type: string): string {
const def = getNodeTypeDef(type)
const prefix = def?.namePrefix ?? 'NODE'
let max = 0
for (const node of nodes) {
if (node.type !== type) continue
const match = /^(\D+)(\d+)$/.exec(node.data.name)
if (match && match[1] === prefix) {
max = Math.max(max, Number(match[2]))
}
}
return `${prefix}${max + 1}`
}
/**
* A free name for a duplicate of `name`: numeric suffixes count up
* (REG2 -> REG3), everything else gets a `_copy` suffix. `used` holds
* lower-cased taken names.
*/
export function duplicateName(name: string, used: Set<string>): string {
const numbered = /^(.*?)(\d+)$/.exec(name)
if (numbered) {
let n = Number(numbered[2]) + 1
while (used.has(`${numbered[1]}${n}`.toLowerCase())) n++
return `${numbered[1]}${n}`
}
let candidate = `${name}_copy`
let n = 2
while (used.has(candidate.toLowerCase())) candidate = `${name}_copy${n++}`
return candidate
}
export interface GraphState {
nodes: WmNode[]
edges: WmEdge[]
/** Last known canvas viewport, mirrored from React Flow for serialization. */
viewport: Viewport
/**
* Bumped whenever the graph is replaced wholesale (import, restore, new
* project) so the canvas knows to re-apply the stored viewport.
*/
revision: number
onNodesChange: (changes: NodeChange<WmNode>[]) => void
onEdgesChange: (changes: EdgeChange<WmEdge>[]) => void
onConnect: (connection: Connection) => void
/** Create a node of a registry type at a canvas position (flow coords). */
addNode: (type: string, position: XYPosition) => void
/** Shallow-merge a patch into a node's data (name, doc, params). */
updateNodeData: (id: string, patch: Partial<WmNodeData>) => void
/** Delete all currently selected nodes and edges. */
deleteSelection: () => void
/** Duplicate the selected nodes (plus edges between them), offset. */
duplicateSelection: () => void
/** Mirror of React Flow's viewport (called on pan/zoom). */
storeViewport: (viewport: Viewport) => void
/** Replace the whole graph (import/restore/new); bumps `revision`. */
loadGraph: (nodes: WmNode[], edges: WmEdge[], viewport: Viewport) => void
}
export const useGraphStore = create<GraphState>()((set, get) => ({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
revision: 0,
onNodesChange: (changes) => {
// Deleting via keyboard arrives as remove changes; the paired edge
// removals coalesce into the same undo step (shared key + time window).
if (changes.some((c) => c.type === 'remove')) checkpoint('rf:remove')
set({ nodes: applyNodeChanges(changes, get().nodes) })
},
onEdgesChange: (changes) => {
if (changes.some((c) => c.type === 'remove')) checkpoint('rf:remove')
set({ edges: applyEdgeChanges(changes, get().edges) })
},
onConnect: (connection) => {
const { nodes, edges } = get()
const verdict = checkConnection(nodes, edges, connection)
if (!verdict.ok || !verdict.kind) return
checkpoint()
const edge: WmEdge = {
...connection,
id: crypto.randomUUID(),
type: 'smoothstep',
className: `edge-${verdict.kind}`,
data: { kind: verdict.kind },
}
set({ edges: addEdge(edge, edges) })
},
addNode: (type, position) => {
const def = getNodeTypeDef(type)
if (!def) return
checkpoint()
const nodes = get().nodes
const node: WmNode = {
id: crypto.randomUUID(),
type: def.type,
position,
data: {
name: nextNodeName(nodes, type),
doc: '',
params: { ...def.defaultParams },
},
selected: true,
}
set({
// Newly added node becomes the sole selection.
nodes: [...nodes.map((n) => ({ ...n, selected: false })), node],
})
},
updateNodeData: (id, patch) => {
// Coalesced per node so a typing burst in the inspector is one undo step.
checkpoint(`node:${id}`)
set({
nodes: get().nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
...patch,
params: { ...node.data.params, ...patch.params },
},
}
: node,
),
})
},
deleteSelection: () => {
const { nodes, edges } = get()
const goneNodes = new Set(nodes.filter((n) => n.selected).map((n) => n.id))
if (goneNodes.size === 0 && !edges.some((e) => e.selected)) return
checkpoint()
set({
nodes: nodes.filter((n) => !goneNodes.has(n.id)),
edges: edges.filter(
(e) =>
!e.selected && !goneNodes.has(e.source) && !goneNodes.has(e.target),
),
})
},
duplicateSelection: () => {
const { nodes, edges } = get()
const selected = nodes.filter((n) => n.selected)
if (selected.length === 0) return
checkpoint()
const used = new Set(nodes.map((n) => n.data.name.toLowerCase()))
const idMap = new Map<string, string>()
const copies = selected.map((node) => {
const id = crypto.randomUUID()
idMap.set(node.id, id)
const name = duplicateName(node.data.name, used)
used.add(name.toLowerCase())
return {
...node,
id,
position: { x: node.position.x + 32, y: node.position.y + 32 },
selected: true,
data: { ...node.data, name, params: { ...node.data.params } },
}
})
// Wires whose both ends were duplicated come along.
const copiedEdges = edges
.filter((e) => idMap.has(e.source) && idMap.has(e.target))
.map((e) => ({
...e,
id: crypto.randomUUID(),
source: idMap.get(e.source) ?? e.source,
target: idMap.get(e.target) ?? e.target,
selected: false,
}))
set({
nodes: [...nodes.map((n) => ({ ...n, selected: false })), ...copies],
edges: [...edges, ...copiedEdges],
})
},
storeViewport: (viewport) => set({ viewport }),
loadGraph: (nodes, edges, viewport) => {
set({ nodes, edges, viewport, revision: get().revision + 1 })
},
}))
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
CATEGORY_COLORS,
CATEGORY_ORDER,
NODE_TYPE_DEFS,
portsOf,
} from './nodeTypes'
describe('node type registry', () => {
it('has unique type ids', () => {
const ids = NODE_TYPE_DEFS.map((d) => d.type)
expect(new Set(ids).size).toBe(ids.length)
})
it('has unique port ids within each type', () => {
for (const def of NODE_TYPE_DEFS) {
const ids = def.ports.map((p) => p.id)
expect(new Set(ids).size, def.type).toBe(ids.length)
}
})
it('gives every category used by a def a color and an order slot', () => {
for (const def of NODE_TYPE_DEFS) {
expect(CATEGORY_COLORS[def.category], def.type).toBeTruthy()
expect(CATEGORY_ORDER).toContain(def.category)
}
})
it('provides default params for every declared field', () => {
for (const def of NODE_TYPE_DEFS) {
for (const field of def.fields) {
expect(
def.defaultParams[field.key],
`${def.type}.${field.key}`,
).toBeDefined()
}
}
})
it('covers the block set', () => {
const ids = NODE_TYPE_DEFS.map((d) => d.type)
for (const required of [
'register',
'registerFile',
'alu',
'memory',
'pc',
'flags',
'decoder',
'control',
'constant',
'comment',
]) {
expect(ids).toContain(required)
}
})
it('splits ports by direction', () => {
const alu = NODE_TYPE_DEFS.find((d) => d.type === 'alu')
expect(alu).toBeDefined()
if (!alu) return
expect(portsOf(alu, 'in').map((p) => p.id)).toEqual(['a', 'b', 'op'])
expect(portsOf(alu, 'out').map((p) => p.id)).toEqual(['result', 'flags'])
})
})
+285
View File
@@ -0,0 +1,285 @@
import type { Edge, Node } from '@xyflow/react'
/** Wires carry either bus data or control signals; the kinds never mix. */
export type PortKind = 'data' | 'control'
export interface PortDef {
/** Handle id, unique within the node type. */
id: string
label: string
kind: PortKind
direction: 'in' | 'out'
}
export type NodeCategory =
'storage' | 'compute' | 'memory' | 'control' | 'annotation'
/** Toolbox grouping / legend order. */
export const CATEGORY_ORDER: NodeCategory[] = [
'storage',
'compute',
'memory',
'control',
'annotation',
]
export const CATEGORY_LABELS: Record<NodeCategory, string> = {
storage: 'Storage',
compute: 'Compute',
memory: 'Memory',
control: 'Control',
annotation: 'Notes',
}
/** Scratch-inspired category palette (shared with editor.css and minimap). */
export const CATEGORY_COLORS: Record<NodeCategory, string> = {
storage: '#4c97ff',
compute: '#59c059',
memory: '#ff8c1a',
control: '#9966ff',
annotation: '#e6b800',
}
/** Editable property, rendered as a form field by the inspector. */
export interface FieldDef {
key: string
label: string
kind: 'number' | 'text' | 'textarea'
min?: number
max?: number
help?: string
}
/** Data stored on every graph node. */
export interface WmNodeData extends Record<string, unknown> {
/** User-facing instance name, e.g. "REG1". */
name: string
/** Free-form documentation shown in the inspector. */
doc: string
/** Type-specific parameters, keyed by FieldDef.key. */
params: Record<string, string | number>
}
export type WmNode = Node<WmNodeData>
export type WmEdgeData = { kind: PortKind }
export type WmEdge = Edge<WmEdgeData>
export interface NodeTypeDef {
/** Registry key; also the React Flow node `type` and the JSON type id. */
type: string
label: string
category: NodeCategory
description: string
/** Prefix for auto-generated instance names (REG1, REG2, …). */
namePrefix: string
ports: PortDef[]
fields: FieldDef[]
defaultParams: Record<string, string | number>
/** One-line parameter summary shown on the node body. */
summary?: (params: Record<string, string | number>) => string
}
// Width caps at 32: the v1 execution model computes with 32-bit arithmetic
// (see docs/execution-model.md §2).
const width = (def = 8): FieldDef => ({
key: 'width',
label: 'Width (bits)',
kind: 'number',
min: 1,
max: 32,
help: `Bus width in bits (default ${def})`,
})
export const NODE_TYPE_DEFS: NodeTypeDef[] = [
{
type: 'register',
label: 'Register',
category: 'storage',
description: 'A single storage cell that latches its input on Load.',
namePrefix: 'REG',
ports: [
{ id: 'in', label: 'Data in', kind: 'data', direction: 'in' },
{ id: 'load', label: 'Load', kind: 'control', direction: 'in' },
{ id: 'out', label: 'Data out', kind: 'data', direction: 'out' },
],
fields: [width()],
defaultParams: { width: 8 },
summary: (p) => `${p.width}-bit`,
},
{
type: 'registerFile',
label: 'Register File',
category: 'storage',
description: 'A bank of general-purpose registers with two read ports.',
namePrefix: 'RF',
ports: [
{ id: 'wdata', label: 'Write data', kind: 'data', direction: 'in' },
{ id: 'wen', label: 'Write enable', kind: 'control', direction: 'in' },
{ id: 'sel', label: 'Select', kind: 'control', direction: 'in' },
{ id: 'ra', label: 'Read A', kind: 'data', direction: 'out' },
{ id: 'rb', label: 'Read B', kind: 'data', direction: 'out' },
],
fields: [
{
key: 'count',
label: 'Registers',
kind: 'number',
min: 1,
max: 64,
help: 'Number of registers in the file',
},
width(),
],
defaultParams: { count: 4, width: 8 },
summary: (p) => `${p.count} × ${p.width}-bit`,
},
{
type: 'pc',
label: 'Program Counter',
category: 'storage',
description:
'Holds the address of the next instruction; increments or loads a branch target.',
namePrefix: 'PC',
ports: [
{ id: 'next', label: 'Next addr', kind: 'data', direction: 'in' },
{ id: 'load', label: 'Load', kind: 'control', direction: 'in' },
{ id: 'inc', label: 'Increment', kind: 'control', direction: 'in' },
{ id: 'out', label: 'Address', kind: 'data', direction: 'out' },
],
fields: [width(16)],
defaultParams: { width: 16 },
summary: (p) => `${p.width}-bit`,
},
{
type: 'flags',
label: 'Flags',
category: 'storage',
description:
'Status flags (e.g. Zero, Negative, Carry) latched from the ALU.',
namePrefix: 'FLAGS',
ports: [
{ id: 'in', label: 'Update', kind: 'data', direction: 'in' },
{ id: 'latch', label: 'Latch', kind: 'control', direction: 'in' },
{ id: 'out', label: 'Flags', kind: 'data', direction: 'out' },
],
fields: [
{
key: 'flags',
label: 'Flag names',
kind: 'text',
help: 'Comma-separated, e.g. Z,N,C',
},
],
defaultParams: { flags: 'Z,N,C' },
summary: (p) => String(p.flags),
},
{
type: 'alu',
label: 'ALU',
category: 'compute',
description:
'Arithmetic/logic unit: combines inputs A and B under a selected operation.',
namePrefix: 'ALU',
ports: [
{ id: 'a', label: 'A', kind: 'data', direction: 'in' },
{ id: 'b', label: 'B', kind: 'data', direction: 'in' },
{ id: 'op', label: 'Op select', kind: 'control', direction: 'in' },
{ id: 'result', label: 'Result', kind: 'data', direction: 'out' },
{ id: 'flags', label: 'Flags', kind: 'data', direction: 'out' },
],
fields: [width()],
defaultParams: { width: 8 },
summary: (p) => `${p.width}-bit`,
},
{
type: 'constant',
label: 'Constant',
category: 'compute',
description: 'A fixed value driven onto a bus.',
namePrefix: 'CONST',
ports: [{ id: 'out', label: 'Value', kind: 'data', direction: 'out' }],
fields: [{ key: 'value', label: 'Value', kind: 'number', min: 0 }, width()],
defaultParams: { value: 0, width: 8 },
summary: (p) => `${p.value} (${p.width}-bit)`,
},
{
type: 'memory',
label: 'Memory',
category: 'memory',
description: 'Addressable storage for program and data.',
namePrefix: 'MEM',
ports: [
{ id: 'addr', label: 'Address', kind: 'data', direction: 'in' },
{ id: 'din', label: 'Data in', kind: 'data', direction: 'in' },
{ id: 'write', label: 'Write', kind: 'control', direction: 'in' },
{ id: 'read', label: 'Read', kind: 'control', direction: 'in' },
{ id: 'dout', label: 'Data out', kind: 'data', direction: 'out' },
],
fields: [
{
key: 'size',
label: 'Size (words)',
kind: 'number',
min: 16,
max: 65536,
},
width(),
],
defaultParams: { size: 256, width: 8 },
summary: (p) => `${p.size} × ${p.width}-bit`,
},
{
type: 'decoder',
label: 'Instruction Decoder',
category: 'control',
description:
'Splits a fetched instruction into opcode, operands, and control lines.',
namePrefix: 'DEC',
ports: [
{ id: 'instr', label: 'Instruction', kind: 'data', direction: 'in' },
{ id: 'ctl', label: 'Controls', kind: 'control', direction: 'out' },
{ id: 'imm', label: 'Immediate', kind: 'data', direction: 'out' },
],
fields: [],
defaultParams: {},
},
{
type: 'control',
label: 'Control Unit',
category: 'control',
description:
'Orchestrates the datapath: turns decoded instructions and flags into control signals.',
namePrefix: 'CTL',
ports: [
{ id: 'decoded', label: 'Decoded', kind: 'control', direction: 'in' },
{ id: 'flags', label: 'Flags', kind: 'data', direction: 'in' },
{ id: 'signals', label: 'Signals', kind: 'control', direction: 'out' },
],
fields: [],
defaultParams: {},
},
{
type: 'comment',
label: 'Comment',
category: 'annotation',
description: 'A sticky note for documenting the design. Has no wires.',
namePrefix: 'NOTE',
ports: [],
fields: [{ key: 'text', label: 'Text', kind: 'textarea' }],
defaultParams: { text: 'Double-click to edit…' },
},
]
export const NODE_TYPES_BY_ID: ReadonlyMap<string, NodeTypeDef> = new Map(
NODE_TYPE_DEFS.map((def) => [def.type, def]),
)
export function getNodeTypeDef(
type: string | undefined,
): NodeTypeDef | undefined {
return type ? NODE_TYPES_BY_ID.get(type) : undefined
}
export function portsOf(def: NodeTypeDef, direction: 'in' | 'out'): PortDef[] {
return def.ports.filter((p) => p.direction === direction)
}
+375
View File
@@ -0,0 +1,375 @@
import { useEffect, useRef } from 'react'
import { Button } from '../ui/Button'
import './debugger.css'
import { debugSession, useDebugStore, type DebugStatus } from './debugStore'
import { formatValue, SPEED_PRESETS, type SpeedId } from './debugUtils'
/** The Run view: execution controls, registers/flags, memory, and trace. */
const STATUS_LABELS: Record<DebugStatus, string> = {
idle: 'Not loaded',
unavailable: 'Engine unavailable',
paused: 'Paused',
running: 'Running',
halted: 'Halted',
error: 'Error',
}
function Controls() {
const status = useDebugStore((s) => s.status)
const statusDetail = useDebugStore((s) => s.statusDetail)
const speed = useDebugStore((s) => s.speed)
const instructionCount = useDebugStore((s) => s.instructionCount)
const cycleCount = useDebugStore((s) => s.cycleCount)
const store = useDebugStore.getState()
const canExecute = status === 'paused'
const running = status === 'running'
return (
<div className="dbg-controls">
<Button
variant="solid"
onClick={() => void store.loadIntoEngine()}
title="Compile the design, assemble the active program, and load both into the engine"
data-testid="dbg-load"
>
{status === 'idle' || status === 'unavailable' ? 'Load' : 'Reload'}
</Button>
<Button
onClick={store.reset}
disabled={status === 'idle' || status === 'unavailable' || running}
data-testid="dbg-reset"
>
Reset
</Button>
<Button
onClick={store.stepOnce}
disabled={!canExecute}
data-testid="dbg-step"
title="Execute one instruction"
>
Step
</Button>
{running ? (
<Button onClick={store.pause} data-testid="dbg-pause">
Pause
</Button>
) : (
<Button
onClick={store.startRun}
disabled={!canExecute}
data-testid="dbg-run"
title="Run until a breakpoint, halt, or pause"
>
Run
</Button>
)}
<select
aria-label="Speed"
data-testid="dbg-speed"
value={speed}
onChange={(e) => store.setSpeed(e.target.value as SpeedId)}
>
{SPEED_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
<span
className={`dbg-status dbg-status-${status}`}
data-testid="dbg-status"
>
{STATUS_LABELS[status]}
{statusDetail ? ` - ${statusDetail}` : ''}
</span>
<span className="dbg-counters" data-testid="dbg-counters">
{instructionCount} instr · {cycleCount} µops
</span>
</div>
)
}
function Registers() {
const registers = useDebugStore((s) => s.registers)
const pc = useDebugStore((s) => s.pc)
const numberBase = useDebugStore((s) => s.numberBase)
const setNumberBase = useDebugStore((s) => s.setNumberBase)
const currentLine = useDebugStore((s) => s.currentLine)
const session = debugSession()
const currentText =
currentLine !== null
? (session?.sourceLines[currentLine - 1] ?? '').trim()
: null
return (
<section className="dbg-card" data-testid="dbg-registers">
<header className="dbg-card-head">
<h3>Registers</h3>
<div className="dbg-base-toggle" role="group" aria-label="Number base">
{(['hex', 'dec', 'bin'] as const).map((base) => (
<button
key={base}
type="button"
className={
numberBase === base ? 'dbg-base dbg-base-active' : 'dbg-base'
}
onClick={() => setNumberBase(base)}
>
{base}
</button>
))}
</div>
</header>
<div className="dbg-reg-row">
<span className="dbg-reg-name">PC</span>
<span className="dbg-reg-value" data-testid="dbg-pc">
{formatValue(pc, 16, numberBase)}
</span>
</div>
{registers.map((bank) =>
bank.values.map((value, i) => (
<div className="dbg-reg-row" key={`${bank.name}${i}`}>
<span className="dbg-reg-name">
{bank.values.length === 1 ? bank.name : `${bank.name}${i}`}
</span>
<span className="dbg-reg-value">
{formatValue(value, bank.width, numberBase)}
</span>
</div>
)),
)}
<div className="dbg-current" data-testid="dbg-current">
{currentText !== null ? (
<>
next: <code>{currentText || '(blank line)'}</code>
</>
) : (
'next: (outside the program)'
)}
</div>
</section>
)
}
function Flags() {
const flags = useDebugStore((s) => s.flags)
return (
<section className="dbg-card" data-testid="dbg-flags">
<header className="dbg-card-head">
<h3>Flags</h3>
</header>
<div className="dbg-flag-row">
{flags.length === 0 ? (
<span className="dbg-muted">none defined</span>
) : (
flags.map((flag) => (
<span
key={flag.name}
className={flag.value ? 'dbg-flag dbg-flag-set' : 'dbg-flag'}
data-testid={`dbg-flag-${flag.name}`}
>
{flag.name}
</span>
))
)}
</div>
</section>
)
}
const PAGE_SIZE = 256
function MemoryView() {
const tick = useDebugStore((s) => s.tick)
const memoryName = useDebugStore((s) => s.memoryName)
const memoryPage = useDebugStore((s) => s.memoryPage)
const followPc = useDebugStore((s) => s.followPc)
const pc = useDebugStore((s) => s.pc)
const status = useDebugStore((s) => s.status)
const store = useDebugStore.getState()
void tick // reading tick subscribes this component to state changes
const session = debugSession()
const memory = session?.layout.memories.find((m) => m.name === memoryName)
const page = followPc && memory ? Math.floor(pc / PAGE_SIZE) : memoryPage
const base = page * PAGE_SIZE
let words: number[] = []
if (session && memory) {
const view = session.engine.stateView()
const start = memory.offset + Math.min(base, memory.size)
const end = memory.offset + Math.min(base + PAGE_SIZE, memory.size)
words = Array.from(view.subarray(start, end))
}
const digits = memory ? Math.ceil(memory.width / 4) : 2
const isProgramMemory = memory && session
const pcInPage = isProgramMemory && pc >= base && pc < base + PAGE_SIZE
const editable =
status === 'paused' || status === 'halted' || status === 'error'
const onCell = (index: number) => {
if (!memory || !editable) return
const address = base + index
const current = words[index] ?? 0
const raw = window.prompt(
`${memory.name}[0x${address.toString(16).toUpperCase()}] = 0x${current
.toString(16)
.toUpperCase()} - new value (dec or 0x…):`,
)
if (raw === null) return
const value = raw.trim().toLowerCase().startsWith('0x')
? Number.parseInt(raw.trim().slice(2), 16)
: Number.parseInt(raw.trim(), 10)
if (Number.isFinite(value)) store.writeMemory(memory.name, address, value)
}
return (
<section className="dbg-card dbg-memory" data-testid="dbg-memory">
<header className="dbg-card-head">
<h3>Memory</h3>
<div className="dbg-mem-controls">
<select
aria-label="Memory"
value={memoryName ?? ''}
onChange={(e) => store.setMemoryName(e.target.value)}
>
{(session?.layout.memories ?? []).map((m) => (
<option key={m.name} value={m.name}>
{m.name}
</option>
))}
</select>
<Button
variant="icon"
onClick={() => store.setMemoryPage(page - 1)}
disabled={page === 0}
aria-label="Previous page"
>
</Button>
<span className="dbg-mem-page">
@{base.toString(16).toUpperCase().padStart(4, '0')}
</span>
<Button
variant="icon"
onClick={() => store.setMemoryPage(page + 1)}
disabled={!memory || base + PAGE_SIZE >= memory.size}
aria-label="Next page"
>
</Button>
<label className="dbg-follow">
<input
type="checkbox"
checked={followPc}
onChange={(e) => store.setFollowPc(e.target.checked)}
/>
follow PC
</label>
</div>
</header>
{memory ? (
<div className="dbg-mem-grid" data-testid="dbg-mem-grid">
{Array.from({ length: Math.ceil(words.length / 16) }, (_, row) => (
<div className="dbg-mem-row" key={row}>
<span className="dbg-mem-addr">
{(base + row * 16).toString(16).toUpperCase().padStart(4, '0')}
</span>
{words.slice(row * 16, row * 16 + 16).map((value, col) => {
const index = row * 16 + col
const isPc = pcInPage && base + index === pc
return (
<button
key={col}
type="button"
className={
isPc ? 'dbg-mem-cell dbg-mem-pc' : 'dbg-mem-cell'
}
onClick={() => onCell(index)}
title={editable ? 'Click to edit' : undefined}
>
{value.toString(16).toUpperCase().padStart(digits, '0')}
</button>
)
})}
<span className="dbg-mem-ascii">
{words
.slice(row * 16, row * 16 + 16)
.map((v) =>
v >= 32 && v < 127 ? String.fromCharCode(v) : '·',
)
.join('')}
</span>
</div>
))}
</div>
) : (
<p className="dbg-muted">Load a program to inspect memory.</p>
)}
</section>
)
}
function Trace() {
const trace = useDebugStore((s) => s.trace)
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'nearest' })
}, [trace])
return (
<section className="dbg-card dbg-trace" data-testid="dbg-trace">
<header className="dbg-card-head">
<h3>Trace</h3>
</header>
<div className="dbg-trace-list">
{trace.length === 0 ? (
<span className="dbg-muted">
Step or run to see executed instructions here.
</span>
) : (
trace.map((entry, i) => <div key={i}>{entry.text}</div>)
)}
<div ref={endRef} />
</div>
</section>
)
}
export function DebuggerView() {
const status = useDebugStore((s) => s.status)
const statusDetail = useDebugStore((s) => s.statusDetail)
return (
<div className="dbg-view" data-testid="debugger-view">
<Controls />
{status === 'unavailable' ? (
<p className="dbg-unavailable">
The emulator engine is not available:{' '}
{useDebugStore.getState().statusDetail}
</p>
) : null}
{status === 'idle' && !statusDetail ? (
<p className="dbg-idle-hint" data-testid="dbg-idle-hint">
Nothing is loaded yet - press <strong>Load</strong> to compile the
design, assemble the active program, and start debugging (or use the
toolbar <strong>Run</strong> button from anywhere).
</p>
) : null}
<div className="dbg-grid">
<div className="dbg-col-left">
<Registers />
<Flags />
</div>
<div className="dbg-col-right">
<MemoryView />
<Trace />
</div>
</div>
</div>
)
}
+377
View File
@@ -0,0 +1,377 @@
import { create } from 'zustand'
import { assemble, type SourceMapEntry } from '../asm/assembler'
import { activeProgramOf, useProgramStore } from '../asm/programStore'
import { setExecLine } from '../asm/editorNav'
import { useGraphStore } from '../editor/graphStore'
import { useIsaStore } from '../isa/isaStore'
import { compileGraph } from '../machine/compileGraph'
import {
addressToLine,
batchForFrame,
lineToAddress,
pushTrace,
type SpeedId,
} from './debugUtils'
import type { EmulatorEngine, StateLayout } from './engine'
import { loadWasmEngine } from './wasmLoader'
export type DebugStatus =
| 'idle' // nothing loaded yet
| 'unavailable' // wasm engine missing
| 'paused'
| 'running'
| 'halted'
| 'error'
export interface TraceEntry {
text: string
}
export interface RegisterSnapshot {
name: string
width: number
values: number[]
}
interface Session {
engine: EmulatorEngine
layout: StateLayout
sourceMap: SourceMapEntry[]
programId: string
sourceLines: string[]
}
// Module-level (non-reactive) session handle.
let session: Session | null = null
let rafHandle: number | null = null
let lastFrameTime = 0
let accumulatedMs = 0
export function debugSession(): Session | null {
return session
}
export interface DebugState {
status: DebugStatus
statusDetail: string
/** Bumped after every state change so views re-read the state buffer. */
tick: number
pc: number
currentLine: number | null
instructionCount: number
cycleCount: number
flags: { name: string; value: boolean }[]
registers: RegisterSnapshot[]
trace: TraceEntry[]
speed: SpeedId
numberBase: 'hex' | 'dec' | 'bin'
/** Source-line breakpoints, keyed by program id. */
breakpointLines: Record<string, number[]>
memoryName: string | null
memoryPage: number
followPc: boolean
loadIntoEngine: () => Promise<void>
stepOnce: () => void
startRun: () => void
pause: () => void
reset: () => void
setSpeed: (speed: SpeedId) => void
setNumberBase: (base: 'hex' | 'dec' | 'bin') => void
toggleBreakpointLine: (programId: string, line: number) => void
setMemoryName: (name: string) => void
setMemoryPage: (page: number) => void
setFollowPc: (follow: boolean) => void
/** Write a memory word while paused (via the state view). */
writeMemory: (name: string, address: number, value: number) => void
}
function stopRaf() {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle)
rafHandle = null
}
}
export const useDebugStore = create<DebugState>()((set, get) => {
/**
* Re-read cheap state (registers/flags/counters) from the engine.
* `running` keeps the run loop alive visually (and suppresses the
* editor's exec-line highlight while executing at speed).
*/
function refreshSnapshot(running = false) {
if (!session) return
const { engine, layout, sourceMap } = session
const view = engine.stateView()
const pc = view[layout.pc] ?? 0
const halted = ((view[layout.status] ?? 0) & 1) !== 0
const errored = ((view[layout.status] ?? 0) & 2) !== 0
const currentLine = addressToLine(sourceMap, pc)
setExecLine(running ? null : currentLine)
let status: DebugStatus = running ? 'running' : 'paused'
let statusDetail = ''
if (errored) {
status = 'error'
statusDetail = engine.lastError()
} else if (halted) {
status = 'halted'
}
set({
tick: get().tick + 1,
pc,
currentLine,
instructionCount: view[layout.instructionCount] ?? 0,
cycleCount: view[layout.cycleCount] ?? 0,
flags: layout.flags.names.map((name, i) => ({
name,
value: (view[layout.flags.offset + i] ?? 0) !== 0,
})),
registers: layout.banks.map((bank) => ({
name: bank.name,
width: bank.width,
values: Array.from(
view.subarray(bank.offset, bank.offset + bank.count),
),
})),
status,
statusDetail,
})
}
function traceLineText(pc: number): string {
if (!session) return ''
const line = addressToLine(session.sourceMap, pc)
const text =
line !== null ? (session.sourceLines[line - 1] ?? '').trim() : '?'
return `@${pc.toString(16).toUpperCase().padStart(4, '0')} ${text}`
}
function applyBreakpoints() {
if (!session) return
session.engine.clearBreakpoints()
const lines = get().breakpointLines[session.programId] ?? []
for (const line of lines) {
const address = lineToAddress(session.sourceMap, line)
if (address !== null) session.engine.setBreakpoint(address)
}
}
function breakpointAddresses(): Set<number> {
if (!session) return new Set()
const lines = get().breakpointLines[session.programId] ?? []
const addresses = new Set<number>()
for (const line of lines) {
const address = lineToAddress(session.sourceMap, line)
if (address !== null) addresses.add(address)
}
return addresses
}
function stopRun(reason?: string) {
stopRaf()
// Always re-read: the run loop's last batch changed pc/registers.
refreshSnapshot()
if (reason) {
set({ trace: pushTrace(get().trace, { text: reason }) })
}
}
function frame(now: number) {
rafHandle = null
const state = get()
if (state.status !== 'running' || !session) return
const { engine } = session
accumulatedMs += now - lastFrameTime
lastFrameTime = now
const { batch, consumeMs } = batchForFrame(state.speed, accumulatedMs)
accumulatedMs -= consumeMs
if (batch > 0) {
const executed = engine.run(batch)
const view = engine.stateView()
const statusWord = view[session.layout.status] ?? 0
const pc = view[session.layout.pc] ?? 0
if ((statusWord & 1) !== 0) {
stopRun('■ halted')
return
}
if ((statusWord & 2) !== 0) {
stopRun(`✗ error: ${engine.lastError()}`)
return
}
if (executed > 0 && breakpointAddresses().has(pc)) {
stopRun(
`● breakpoint at @${pc.toString(16).toUpperCase().padStart(4, '0')}`,
)
return
}
// Keep the UI live while running (cheap snapshot each frame).
refreshSnapshot(true)
}
rafHandle = requestAnimationFrame(frame)
}
return {
status: 'idle',
statusDetail: '',
tick: 0,
pc: 0,
currentLine: null,
instructionCount: 0,
cycleCount: 0,
flags: [],
registers: [],
trace: [],
speed: 'normal',
numberBase: 'hex',
breakpointLines: {},
memoryName: null,
memoryPage: 0,
followPc: false,
loadIntoEngine: async () => {
stopRaf()
const result = await loadWasmEngine()
if (!result.ok) {
set({ status: 'unavailable', statusDetail: result.reason })
return
}
const engine = result.engine
const { nodes, edges } = useGraphStore.getState()
const compiled = compileGraph(nodes, edges)
if (!compiled.model) {
const firstError = compiled.diagnostics.find(
(d) => d.severity === 'error',
)
set({
status: 'idle',
statusDetail: `design does not compile: ${firstError?.message ?? 'unknown error'}`,
})
return
}
const isa = useIsaStore.getState().isa
const program = activeProgramOf(useProgramStore.getState())
if (!program) {
set({ status: 'idle', statusDetail: 'no program to assemble' })
return
}
const assembled = assemble(program.source, isa, compiled.model)
if (!assembled.ok) {
const firstError = assembled.diagnostics.find(
(d) => d.severity === 'error',
)
set({
status: 'idle',
statusDetail: `assembly failed: line ${firstError?.line}: ${firstError?.message ?? ''}`,
})
useProgramStore.getState().setResult(program.id, assembled)
return
}
useProgramStore.getState().setResult(program.id, assembled)
if (
!engine.loadModel(JSON.stringify(compiled.model)) ||
!engine.loadIsa(JSON.stringify(isa)) ||
!engine.loadProgram(JSON.stringify(assembled.segments))
) {
set({
status: 'idle',
statusDetail: `engine rejected the project: ${engine.lastError()}`,
})
return
}
session = {
engine,
layout: engine.stateLayout(),
sourceMap: assembled.sourceMap,
programId: program.id,
sourceLines: program.source.split(/\r?\n/),
}
applyBreakpoints()
set({
trace: pushTrace(get().trace, {
text: `▶ loaded "${program.name}" (${assembled.segments.reduce((n, s) => n + s.values.length, 0)} words)`,
}),
memoryName: session.layout.memories[0]?.name ?? null,
memoryPage: 0,
statusDetail: '',
})
refreshSnapshot()
},
stepOnce: () => {
if (!session || get().status === 'running') return
const pcBefore = session.engine.stateView()[session.layout.pc] ?? 0
const executed = session.engine.step(1)
if (executed > 0) {
set({
trace: pushTrace(get().trace, { text: traceLineText(pcBefore) }),
})
}
refreshSnapshot()
},
startRun: () => {
const state = get()
if (!session || state.status === 'running') return
if (state.status === 'halted' || state.status === 'error') return
set({
status: 'running',
trace: pushTrace(state.trace, { text: '▶ running…' }),
})
setExecLine(null)
lastFrameTime = performance.now()
accumulatedMs = 0
rafHandle = requestAnimationFrame(frame)
},
pause: () => {
if (get().status !== 'running') return
stopRun('⏸ paused')
},
reset: () => {
if (!session) return
stopRaf()
session.engine.reset()
set({ trace: pushTrace(get().trace, { text: '↺ reset' }) })
refreshSnapshot()
},
setSpeed: (speed) => set({ speed }),
setNumberBase: (numberBase) => set({ numberBase }),
toggleBreakpointLine: (programId, line) => {
const lines = get().breakpointLines[programId] ?? []
const next = lines.includes(line)
? lines.filter((l) => l !== line)
: [...lines, line].sort((a, b) => a - b)
set({
breakpointLines: { ...get().breakpointLines, [programId]: next },
})
if (session && session.programId === programId) applyBreakpoints()
},
setMemoryName: (memoryName) => set({ memoryName, memoryPage: 0 }),
setMemoryPage: (memoryPage) =>
set({ memoryPage: Math.max(0, memoryPage), followPc: false }),
setFollowPc: (followPc) => set({ followPc }),
writeMemory: (name, address, value) => {
if (!session || get().status === 'running') return
// Editing is allowed while paused, halted, or errored.
const memory = session.layout.memories.find((m) => m.name === name)
if (!memory || address < 0 || address >= memory.size) return
const mask = memory.width >= 32 ? 0xffffffff : (1 << memory.width) - 1
const view = session.engine.stateView()
view[memory.offset + address] = (value & mask) >>> 0
refreshSnapshot()
},
}
})
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { SourceMapEntry } from '../asm/assembler'
import {
addressToLine,
batchForFrame,
formatValue,
lineToAddress,
pushTrace,
} from './debugUtils'
const sourceMap: SourceMapEntry[] = [
{ address: 0, line: 2, words: 2 },
{ address: 2, line: 3, words: 2 },
{ address: 6, line: 5, words: 1 },
]
describe('lineToAddress / addressToLine', () => {
it('maps lines with statements to their address', () => {
expect(lineToAddress(sourceMap, 2)).toBe(0)
expect(lineToAddress(sourceMap, 5)).toBe(6)
expect(lineToAddress(sourceMap, 4)).toBeNull() // comment/blank line
})
it('maps addresses inside multi-word instructions to the line', () => {
expect(addressToLine(sourceMap, 0)).toBe(2)
expect(addressToLine(sourceMap, 1)).toBe(2) // second word of the LDI
expect(addressToLine(sourceMap, 2)).toBe(3)
expect(addressToLine(sourceMap, 4)).toBeNull() // gap
expect(addressToLine(sourceMap, 6)).toBe(5)
expect(addressToLine(sourceMap, 7)).toBeNull() // past the program
})
})
describe('batchForFrame', () => {
it('uses fixed batches for per-frame speeds', () => {
expect(batchForFrame('fast', 16)).toEqual({ batch: 10_000, consumeMs: 16 })
expect(batchForFrame('turbo', 3)).toEqual({ batch: 200_000, consumeMs: 3 })
})
it('rations rate-based speeds by accumulated time', () => {
// slow = 2 instr/s -> 500ms per instruction
expect(batchForFrame('slow', 400).batch).toBe(0)
expect(batchForFrame('slow', 600)).toEqual({ batch: 1, consumeMs: 500 })
expect(batchForFrame('slow', 1600).batch).toBe(3)
// normal = 60/s -> one per ~16.7ms
expect(batchForFrame('normal', 17).batch).toBe(1)
})
})
describe('pushTrace', () => {
it('appends and trims to the limit', () => {
let trace: { text: string }[] = []
for (let i = 0; i < 205; i++) {
trace = pushTrace(trace, { text: String(i) })
}
expect(trace).toHaveLength(200)
expect(trace[0]?.text).toBe('5')
expect(trace[199]?.text).toBe('204')
})
})
describe('formatValue', () => {
it('formats hex, dec, and bin with width-appropriate padding', () => {
expect(formatValue(255, 8, 'hex')).toBe('0xFF')
expect(formatValue(5, 16, 'hex')).toBe('0x0005')
expect(formatValue(255, 8, 'dec')).toBe('255')
expect(formatValue(5, 4, 'bin')).toBe('0b0101')
})
})
+75
View File
@@ -0,0 +1,75 @@
import type { SourceMapEntry } from '../asm/assembler'
/** Address of the statement on a source line, or null when nothing maps. */
export function lineToAddress(
sourceMap: SourceMapEntry[],
line: number,
): number | null {
for (const entry of sourceMap) {
if (entry.line === line) return entry.address
}
return null
}
/** Source line whose statement covers `address`, or null. */
export function addressToLine(
sourceMap: SourceMapEntry[],
address: number,
): number | null {
for (const entry of sourceMap) {
if (address >= entry.address && address < entry.address + entry.words) {
return entry.line
}
}
return null
}
/** Execution speed presets: instructions per animation frame (~60fps). */
export const SPEED_PRESETS = [
{ id: 'slow', label: 'Slow (2/s)', perFrame: 0, perSecond: 2 },
{ id: 'normal', label: 'Normal (60/s)', perFrame: 1, perSecond: 60 },
{ id: 'fast', label: 'Fast (10k/frame)', perFrame: 10_000, perSecond: 0 },
{ id: 'turbo', label: 'Turbo (200k/frame)', perFrame: 200_000, perSecond: 0 },
] as const
export type SpeedId = (typeof SPEED_PRESETS)[number]['id']
/**
* How many instructions to execute this frame for a rate-based speed,
* given the time accumulator (ms since instructions last ran).
*/
export function batchForFrame(
speed: SpeedId,
accumulatedMs: number,
): { batch: number; consumeMs: number } {
const preset = SPEED_PRESETS.find((p) => p.id === speed)
if (!preset) return { batch: 0, consumeMs: 0 }
if (preset.perFrame > 0) {
return { batch: preset.perFrame, consumeMs: accumulatedMs }
}
const msPerInstruction = 1000 / preset.perSecond
const batch = Math.floor(accumulatedMs / msPerInstruction)
return { batch, consumeMs: batch * msPerInstruction }
}
/** Append to a bounded trace (newest last). */
export function pushTrace<T>(trace: T[], entry: T, limit = 200): T[] {
const next = [...trace, entry]
return next.length > limit ? next.slice(next.length - limit) : next
}
export function formatValue(
value: number,
width: number,
base: 'hex' | 'dec' | 'bin',
): string {
if (base === 'dec') return String(value)
if (base === 'bin') return '0b' + value.toString(2).padStart(width, '0')
return (
'0x' +
value
.toString(16)
.toUpperCase()
.padStart(Math.ceil(width / 4), '0')
)
}
+321
View File
@@ -0,0 +1,321 @@
.dbg-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
.dbg-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
flex: none;
flex-wrap: wrap;
}
.dbg-controls select {
font: 12.5px var(--sans);
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
}
.dbg-status {
font-size: 13px;
font-weight: 600;
margin-left: 8px;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dbg-status-paused {
color: var(--accent);
}
.dbg-status-running {
color: #2e9e44;
}
.dbg-status-halted {
color: var(--text);
}
.dbg-status-error {
color: #d64545;
}
:root[data-theme='dark'] .dbg-status-running {
color: #6fd784;
}
:root[data-theme='dark'] .dbg-status-error {
color: #ff8a8a;
}
.dbg-counters {
margin-left: auto;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
}
.dbg-unavailable {
padding: 10px 14px;
font-size: 13px;
color: #d64545;
}
.dbg-grid {
flex: 1;
min-height: 0;
display: flex;
gap: 12px;
padding: 12px;
overflow: auto;
}
.dbg-col-left {
display: flex;
flex-direction: column;
gap: 12px;
width: 240px;
flex: none;
}
.dbg-col-right {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-width: 0;
}
.dbg-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.dbg-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.dbg-card-head h3 {
margin: 0;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text);
}
.dbg-muted {
font-size: 12.5px;
color: var(--text);
opacity: 0.7;
}
/* Registers */
.dbg-base-toggle {
display: flex;
gap: 2px;
}
.dbg-base {
font: 11px var(--mono);
padding: 2px 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 5px;
cursor: pointer;
}
.dbg-base-active {
background: var(--accent-bg);
color: var(--accent);
border-color: var(--accent-border);
}
.dbg-reg-row {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 13px;
padding: 2px 0;
}
.dbg-reg-name {
color: var(--text);
}
.dbg-reg-value {
color: var(--text-h);
}
.dbg-current {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Flags */
.dbg-flag-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dbg-flag {
font-family: var(--mono);
font-size: 12px;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text);
opacity: 0.6;
}
.dbg-flag-set {
opacity: 1;
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-bg);
font-weight: 700;
}
/* Memory */
.dbg-memory {
min-height: 0;
}
.dbg-mem-controls {
display: flex;
align-items: center;
gap: 4px;
}
.dbg-mem-page {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
}
.dbg-follow {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text);
margin-left: 6px;
}
.dbg-mem-grid {
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
}
.dbg-mem-row {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.dbg-mem-addr {
color: var(--accent);
margin-right: 6px;
}
.dbg-mem-cell {
font: inherit;
border: none;
background: none;
color: var(--text-h);
padding: 1px 3px;
border-radius: 3px;
cursor: pointer;
}
.dbg-mem-cell:hover {
background: var(--hover-bg);
}
.dbg-mem-pc {
background: var(--accent-bg);
color: var(--accent);
font-weight: 700;
outline: 1px solid var(--accent-border);
}
.dbg-mem-ascii {
margin-left: 8px;
color: var(--text);
opacity: 0.75;
}
/* Trace */
.dbg-trace {
flex: 1;
min-height: 120px;
display: flex;
flex-direction: column;
}
.dbg-trace-list {
flex: 1;
min-height: 0;
overflow-y: auto;
font-family: var(--mono);
font-size: 12px;
color: var(--text-h);
display: flex;
flex-direction: column;
gap: 1px;
}
/* Editor decorations (breakpoints + exec line) */
.cm-breakpoint-gutter {
width: 14px;
cursor: pointer;
}
.cm-breakpoint-dot {
color: #d64545;
font-size: 11px;
line-height: 1;
}
.cm-exec-line {
background: var(--accent-bg) !important;
outline: 1px solid var(--accent-border);
}
.dbg-idle-hint {
margin: 0;
padding: 10px 14px;
border: 1px dashed var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text-muted);
}
+49
View File
@@ -0,0 +1,49 @@
/** Parsed result of the engine's stateLayout() JSON. */
export interface StateLayout {
pc: number
status: number
instructionCount: number
cycleCount: number
flags: { offset: number; names: string[] }
banks: { name: string; offset: number; count: number; width: number }[]
memories: { name: string; offset: number; size: number; width: number }[]
total: number
}
export interface EmulatorEngine {
/** Engine identity, e.g. "webmetal-engine 0.2.0". */
version(): string
/** Load a machine model (JSON from compileGraph). False + lastError on failure. */
loadModel(modelJson: string): boolean
/** Load the ISA definition (JSON.stringify of the project's isa). */
loadIsa(isaJson: string): boolean
/** Load assembled segments (JSON.stringify of AssembleResult.segments). */
loadProgram(segmentsJson: string): boolean
lastError(): string
/** Reset machine state and re-apply the loaded program. */
reset(): void
/** Execute up to n instructions, ignoring breakpoints. */
step(n: number): number
/** Execute until breakpoint/halt/error or maxSteps instructions. */
run(maxSteps: number): number
setBreakpoint(address: number): void
clearBreakpoint(address: number): void
clearBreakpoints(): void
halted(): boolean
errored(): boolean
modelLoaded(): boolean
pc(): number
/** Buffer layout for interpreting stateView() (parse of stateLayout()). */
stateLayout(): StateLayout
/**
* Zero-copy view of the engine's state buffer. The view is INVALID after
* any call that may grow the wasm heap (loadModel/loadIsa/loadProgram) -
* always re-fetch after those calls instead of caching it.
*/
stateView(): Uint32Array
/** Release the underlying C++ object. The engine is unusable afterwards. */
dispose(): void
}
export type EngineLoadResult =
{ ok: true; engine: EmulatorEngine } | { ok: false; reason: string }
+107
View File
@@ -0,0 +1,107 @@
import type { EmulatorEngine, EngineLoadResult, StateLayout } from './engine'
/** The embind class instance surface (mirrors wasm/src/bindings.cpp). */
interface WasmEngineObject {
version(): string
loadModel(json: string): boolean
loadIsa(json: string): boolean
loadProgram(json: string): boolean
lastError(): string
reset(): void
step(n: number): number
run(maxSteps: number): number
setBreakpoint(address: number): void
clearBreakpoint(address: number): void
clearBreakpoints(): void
hasBreakpoint(address: number): boolean
halted(): boolean
errored(): boolean
modelLoaded(): boolean
pc(): number
stateLayout(): string
stateBufferPtr(): number
stateBufferLen(): number
execSequence(microOpsJson: string, operandsJson: string): string
delete(): void
}
interface WebMetalModule {
Engine: new () => WasmEngineObject
HEAPU32: Uint32Array
}
function wrap(module: WebMetalModule, raw: WasmEngineObject): EmulatorEngine {
return {
version: () => raw.version(),
loadModel: (json) => raw.loadModel(json),
loadIsa: (json) => raw.loadIsa(json),
loadProgram: (json) => raw.loadProgram(json),
lastError: () => raw.lastError(),
reset: () => raw.reset(),
step: (n) => raw.step(n),
run: (maxSteps) => raw.run(maxSteps),
setBreakpoint: (address) => raw.setBreakpoint(address),
clearBreakpoint: (address) => raw.clearBreakpoint(address),
clearBreakpoints: () => raw.clearBreakpoints(),
halted: () => raw.halted(),
errored: () => raw.errored(),
modelLoaded: () => raw.modelLoaded(),
pc: () => raw.pc(),
stateLayout: () => JSON.parse(raw.stateLayout()) as StateLayout,
stateView: () => {
// Rebuilt on every call: heap growth replaces the underlying buffer.
const ptr = raw.stateBufferPtr()
return new Uint32Array(module.HEAPU32.buffer, ptr, raw.stateBufferLen())
},
dispose: () => raw.delete(),
}
}
let cached: Promise<EngineLoadResult> | null = null
/**
* Load the wasm module (once) and create the singleton engine.
* Never throws - failures come back as { ok: false, reason }.
*/
export function loadWasmEngine(): Promise<EngineLoadResult> {
cached ??= (async (): Promise<EngineLoadResult> => {
try {
const base = import.meta.env.BASE_URL ?? '/'
const moduleUrl = new URL(
`${base}wasm/webmetal-engine.mjs`,
window.location.href,
).href
// Probe first so a missing artifact yields a clean message instead of
// an import error with a confusing stack. SPA-style servers answer
// missing files with 200 + index.html, so check the content type too.
const probe = await fetch(moduleUrl, { method: 'HEAD' })
const contentType = probe.headers.get('content-type') ?? ''
if (!probe.ok || contentType.includes('text/html')) {
return {
ok: false,
reason:
'engine not built - run scripts/build-wasm.sh',
}
}
const factory = (
(await import(/* @vite-ignore */ moduleUrl)) as {
default: (options?: object) => Promise<WebMetalModule>
}
).default
const module = await factory()
const engine = wrap(module, new module.Engine())
return { ok: true, engine }
} catch (error) {
return {
ok: false,
reason: `failed to load engine: ${error instanceof Error ? error.message : String(error)}`,
}
}
})()
return cached
}
/** Test hook: forget the cached load (not used in production code). */
export function resetWasmLoaderForTests(): void {
cached = null
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react'
import './examples.css'
import { useAppStore } from '../app/store'
import { useGraphStore } from '../editor/graphStore'
import { loadProject, parseProjectJson } from '../model/serialize'
import { Button } from '../ui/Button'
import { EXAMPLES, type ExampleDef } from './index'
/**
* The example picker (native <dialog>). Opening an example replaces the
* whole project, so a confirm guards non-empty designs.
*/
export function ExamplesDialog() {
const open = useAppStore((s) => s.examplesOpen)
const setExamplesOpen = useAppStore((s) => s.setExamplesOpen)
const setView = useAppStore((s) => s.setView)
const ref = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = ref.current
if (!dialog) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
const openExample = (example: ExampleDef) => {
const parsed = parseProjectJson(JSON.stringify(example.raw))
if (!parsed.ok) {
window.alert(`Could not load the example: ${parsed.error}`)
return
}
const hasContent = useGraphStore.getState().nodes.length > 0
if (
hasContent &&
!window.confirm(
`Open "${parsed.doc.metadata.name}"? It replaces the current project - unsaved changes will be lost.`,
)
) {
return
}
loadProject(parsed.doc)
setExamplesOpen(false)
setView('architecture')
}
return (
<dialog
ref={ref}
className="examples-dialog"
onClose={() => setExamplesOpen(false)}
aria-labelledby="examples-title"
data-testid="examples-dialog"
>
<h2 id="examples-title">Example projects</h2>
<p className="examples-intro">
Complete, runnable CPUs - architecture, instruction set, and a demo
program. Open one, press <strong>Run</strong>, and step through it.
</p>
<ul className="examples-list">
{EXAMPLES.map((example) => (
<li key={example.id} className="examples-item">
<div className="examples-text">
<h3>{example.name}</h3>
<p className="examples-tagline">{example.tagline}</p>
<p className="examples-description">{example.description}</p>
</div>
<Button
variant="solid"
onClick={() => openExample(example)}
data-testid={`example-open-${example.id}`}
>
Open
</Button>
</li>
))}
</ul>
<div className="examples-actions">
<Button onClick={() => setExamplesOpen(false)} autoFocus>
Close
</Button>
</div>
</dialog>
)
}
+69
View File
@@ -0,0 +1,69 @@
.examples-dialog {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
color: var(--text);
padding: 24px 28px;
width: min(34rem, calc(100vw - 48px));
box-shadow: var(--shadow);
}
.examples-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.examples-dialog h2 {
margin: 0 0 4px;
font-size: 20px;
}
.examples-intro {
margin: 0 0 14px;
font-size: 13.5px;
color: var(--text-muted);
}
.examples-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.examples-item {
display: flex;
align-items: center;
gap: 14px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.examples-text {
flex: 1;
min-width: 0;
}
.examples-item h3 {
margin: 0;
font-size: 14px;
font-family: var(--mono);
}
.examples-tagline {
margin: 2px 0 4px;
font-size: 12.5px;
font-weight: 600;
}
.examples-description {
margin: 0;
font-size: 12.5px;
color: var(--text-muted);
}
.examples-actions {
margin-top: 16px;
text-align: right;
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { assemble } from '../asm/assembler'
import type { WmEdge, WmNode } from '../editor/nodeTypes'
import { validateIsa } from '../isa/validateIsa'
import { compileGraph } from '../machine/compileGraph'
import {
runProgramReference,
type ReferenceRun,
} from '../machine/cycleReference'
import { parseProjectJson } from '../model/serialize'
import type { ProjectDoc } from '../model/projectSchema'
import { EXAMPLES } from './index'
interface ExampleRun {
doc: ProjectDoc
run: ReferenceRun
}
/** Full pipeline with every stage asserted clean. */
function runExample(id: string): ExampleRun {
const example = EXAMPLES.find((e) => e.id === id)
if (!example) throw new Error(`example "${id}" is not registered`)
const parsed = parseProjectJson(JSON.stringify(example.raw))
if (!parsed.ok) throw new Error(`${id}: ${parsed.error}`)
const doc = parsed.doc
// Rebuild editor-shaped nodes/edges the way loadProject does.
const nodes: WmNode[] = doc.graph.nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
}))
const edges: WmEdge[] = doc.graph.edges.map((e) => ({
id: e.id,
source: e.source,
sourceHandle: e.sourceHandle,
target: e.target,
targetHandle: e.targetHandle,
data: { kind: e.kind },
}))
const compiled = compileGraph(nodes, edges)
expect(compiled.diagnostics).toEqual([])
if (!compiled.model) throw new Error(`${id}: graph does not compile`)
expect(validateIsa(doc.isa, compiled.model)).toEqual([])
const program = doc.programs[0]
if (!program) throw new Error(`${id}: no demo program`)
const assembled = assemble(program.source, doc.isa, compiled.model)
expect(assembled.diagnostics).toEqual([])
expect(assembled.ok).toBe(true)
const run = runProgramReference(
compiled.model,
doc.isa,
assembled.segments,
10_000,
)
expect(run.error).toBeUndefined()
expect(run.outcome).toBe('halted')
return { doc, run }
}
function bankOf(run: ReferenceRun, name: string): number[] {
const values = run.state.banks[name]
if (!values) throw new Error(`missing bank "${name}"`)
return values
}
function memoryOf(run: ReferenceRun, name: string): number[] {
const values = run.state.memories[name]
if (!values) throw new Error(`missing memory "${name}"`)
return values
}
describe('bundled examples', () => {
it('registers three examples with unique ids', () => {
const ids = EXAMPLES.map((e) => e.id)
expect(ids).toEqual(['edu-core', 'toy-cpu', 'retro-8'])
expect(new Set(ids).size).toBe(ids.length)
for (const example of EXAMPLES) {
expect(example.name.length).toBeGreaterThan(0)
expect(example.description.length).toBeGreaterThan(0)
}
})
it('edu-core counts down from 3 and halts', () => {
const { doc, run } = runExample('edu-core')
expect(doc.metadata.name).toBe('EDU-CORE')
// Every block carries documentation.
for (const node of doc.graph.nodes) {
if (node.type === 'comment') continue
expect(node.data.doc.length, node.data.name).toBeGreaterThan(20)
}
expect(run.instructions).toBe(11)
expect(memoryOf(run, 'MEM')[32]).toBe(1)
expect(bankOf(run, 'ACC')).toEqual([0])
expect(run.state.flags['Z']).toBe(true)
})
it('toy-cpu sums 5..1 into R0 and stores it', () => {
const { doc, run } = runExample('toy-cpu')
expect(doc.metadata.name).toBe('TOY-CPU')
expect(run.instructions).toBe(20)
expect(bankOf(run, 'R')).toEqual([15, 0, 1, 0])
expect(memoryOf(run, 'MAIN')[64]).toBe(15)
expect(run.state.flags['Z']).toBe(true)
})
it('retro-8 fills a table with indexed stores', () => {
const { doc, run } = runExample('retro-8')
expect(doc.metadata.name).toBe('RETRO-8')
expect(run.instructions).toBe(28)
expect(memoryOf(run, 'MEM').slice(96, 101)).toEqual([10, 20, 30, 40, 50])
expect(bankOf(run, 'ACC')).toEqual([50])
expect(bankOf(run, 'X')).toEqual([5])
expect(run.state.flags['Z']).toBe(true)
})
})
+45
View File
@@ -0,0 +1,45 @@
import eduCore from '../../../examples/edu-core/edu-core.webmetal.json'
import retro8 from '../../../examples/retro-8/retro-8.webmetal.json'
import toyCpu from '../../../examples/toy-cpu/toy-cpu.webmetal.json'
export interface ExampleDef {
id: string
name: string
tagline: string
description: string
/** Raw project document; validated on open like any import. */
raw: unknown
}
export const EXAMPLES: ExampleDef[] = [
{
id: 'edu-core',
name: 'EDU-CORE',
tagline: 'Start here - one accumulator, six instructions',
description:
'The smallest machine that can run a real loop. Every block and every ' +
'instruction is documented; the demo program counts down from 3, ' +
'storing each value to memory.',
raw: eduCore,
},
{
id: 'toy-cpu',
name: 'TOY-CPU',
tagline: 'Four registers and register-to-register arithmetic',
description:
'Adds a register file with two read ports, load/store instructions, ' +
'and jumps. The demo sums the numbers 5..1 into R0 and stores the ' +
'result to memory.',
raw: toyCpu,
},
{
id: 'retro-8',
name: 'RETRO-8',
tagline: 'An original 8-bit machine with three addressing modes',
description:
'Accumulator plus X index register, Z/N/C flags, and immediate, ' +
'absolute, and X-indexed addressing. The demo fills a five-entry ' +
'table in memory using indexed stores.',
raw: retro8,
},
]
+1
View File
@@ -0,0 +1 @@
declare const __APP_VERSION__: string
+99
View File
@@ -0,0 +1,99 @@
:root {
--text: #5b6472;
--text-h: #0c1220;
--bg: #f5f6f8;
--bg-panel: #ffffff;
--bg-canvas: #eef0f3;
--canvas-dot: #cdd2da;
--border: #e2e5ea;
--hover-bg: rgba(12, 18, 32, 0.06);
--code-bg: #eef1f5;
--accent: #2874d0;
--accent-contrast: #ffffff;
--accent-bg: rgba(40, 116, 208, 0.1);
--accent-border: rgba(40, 116, 208, 0.45);
/* Assembly token colors (used by the CodeMirror highlight style) */
--asm-comment: #8a94a3;
--asm-mnemonic: #2874d0;
--asm-register: #b0501a;
--asm-number: #1a7f4b;
--asm-label: #8340bf;
--asm-directive: #a3760a;
--asm-ident: #0c1220;
--asm-invalid: #d64545;
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 16px/145% var(--sans);
color-scheme: light;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
:root[data-theme='dark'] {
--text: #9aa4b2;
--text-h: #f2f5f9;
--bg: #101216;
--bg-panel: #16191f;
--bg-canvas: #101216;
--canvas-dot: #2c313b;
--border: #2b2f3a;
--hover-bg: rgba(242, 245, 249, 0.08);
--code-bg: #1e222b;
--accent: #6aa9ee;
--accent-contrast: #0c1220;
--accent-bg: rgba(106, 169, 238, 0.14);
--accent-border: rgba(106, 169, 238, 0.5);
--asm-comment: #6f7a89;
--asm-mnemonic: #6aa9ee;
--asm-register: #f0a068;
--asm-number: #6fd784;
--asm-label: #c79bf2;
--asm-directive: #e0c060;
--asm-ident: #f2f5f9;
--asm-invalid: #ff8a8a;
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
color-scheme: dark;
}
body {
margin: 0;
}
h1,
h2 {
font-weight: 550;
color: var(--text-h);
}
p {
margin: 0;
}
code {
font-family: var(--mono);
font-size: 0.85em;
padding: 2px 6px;
border-radius: 4px;
background: var(--code-bg);
color: var(--text-h);
}
/* Keyboard-focus visibility for plain form controls (buttons/tabs have
their own rules in ui.css). */
select:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
+293
View File
@@ -0,0 +1,293 @@
import type { MachineModel } from '../machine/machineModel'
import { Button } from '../ui/Button'
import {
MAX_INSTRUCTION_WORDS,
MAX_OPERANDS,
type Instruction,
} from './isaModel'
import { useIsaStore } from './isaStore'
import { defaultMicroOp } from './microOpDefaults'
import { MicroOpRow } from './MicroOpEditor'
/** Detail editor for one instruction: identity, operands, docs, behavior. */
export function InstructionEditor({
instruction,
model,
}: {
instruction: Instruction
model: MachineModel | null
}) {
const updateInstruction = useIsaStore((s) => s.updateInstruction)
const removeInstruction = useIsaStore((s) => s.removeInstruction)
const addOperand = useIsaStore((s) => s.addOperand)
const updateOperand = useIsaStore((s) => s.updateOperand)
const removeOperand = useIsaStore((s) => s.removeOperand)
const addMicroOp = useIsaStore((s) => s.addMicroOp)
const updateMicroOp = useIsaStore((s) => s.updateMicroOp)
const removeMicroOp = useIsaStore((s) => s.removeMicroOp)
const moveMicroOp = useIsaStore((s) => s.moveMicroOp)
const id = instruction.id
const ctx = { model, operandNames: instruction.operands.map((o) => o.name) }
const patch = (p: Partial<Omit<Instruction, 'id'>>) =>
updateInstruction(id, p)
const toggleFlag = (flag: string) => {
const has = instruction.flagsAffected.includes(flag)
patch({
flagsAffected: has
? instruction.flagsAffected.filter((f) => f !== flag)
: [...instruction.flagsAffected, flag],
})
}
return (
<div className="instr-editor" data-testid="instruction-editor">
<div className="instr-row">
<label className="isa-field">
<span>Mnemonic</span>
<input
type="text"
data-testid="instr-mnemonic"
value={instruction.mnemonic}
onChange={(e) => patch({ mnemonic: e.target.value })}
/>
</label>
<label className="isa-field">
<span>Opcode</span>
<input
type="number"
data-testid="instr-opcode"
min={0}
value={instruction.opcode}
onChange={(e) =>
patch({
opcode: Math.max(0, Math.round(Number(e.target.value) || 0)),
})
}
/>
</label>
<span className="instr-hex">
0x{instruction.opcode.toString(16).toUpperCase().padStart(2, '0')}
</span>
<label className="isa-field">
<span>Words</span>
<input
type="number"
data-testid="instr-words"
min={1}
max={MAX_INSTRUCTION_WORDS}
value={instruction.words}
onChange={(e) =>
patch({
words: Math.min(
MAX_INSTRUCTION_WORDS,
Math.max(1, Math.round(Number(e.target.value) || 1)),
),
})
}
/>
</label>
</div>
<label className="isa-field">
<span>Documentation</span>
<textarea
rows={2}
value={instruction.doc}
placeholder="What does this instruction do?"
onChange={(e) => patch({ doc: e.target.value })}
/>
</label>
<div className="isa-field">
<span>Flags affected (documentation)</span>
{model && model.flags.length > 0 ? (
<div className="instr-flags">
{model.flags.map((flag) => (
<label key={flag} className="mo-inline">
<input
type="checkbox"
checked={instruction.flagsAffected.includes(flag)}
onChange={() => toggleFlag(flag)}
/>
{flag}
</label>
))}
</div>
) : (
<p className="isa-hint">The design defines no flags yet.</p>
)}
</div>
<section className="instr-section">
<header className="instr-section-head">
<h3>Operands</h3>
<Button
onClick={() => addOperand(id)}
disabled={instruction.operands.length >= MAX_OPERANDS}
data-testid="add-operand"
>
Add operand
</Button>
</header>
{instruction.operands.length === 0 ? (
<p className="isa-hint">
No operands - this instruction stands alone.
</p>
) : (
instruction.operands.map((operand, i) => (
<div className="operand-row" key={i} data-testid="operand-row">
<input
type="text"
aria-label="Operand name"
className="operand-name"
value={operand.name}
onChange={(e) => updateOperand(id, i, { name: e.target.value })}
/>
<select
aria-label="Operand kind"
value={operand.kind}
onChange={(e) =>
updateOperand(id, i, {
kind: e.target.value as typeof operand.kind,
})
}
>
<option value="register">register</option>
<option value="immediate">immediate (#n)</option>
<option value="address">address</option>
</select>
{operand.kind === 'register' ? (
<select
aria-label="Operand bank"
value={operand.bank ?? ''}
onChange={(e) =>
updateOperand(id, i, { bank: e.target.value })
}
>
<option value="">- bank -</option>
{(model?.banks ?? []).map((bank) => (
<option key={bank.name} value={bank.name}>
{bank.name}
</option>
))}
</select>
) : null}
<label className="mo-inline">
word
<input
type="number"
aria-label="Field word"
min={0}
max={MAX_INSTRUCTION_WORDS - 1}
value={operand.field.word}
onChange={(e) =>
updateOperand(id, i, {
field: {
...operand.field,
word: Math.max(
0,
Math.round(Number(e.target.value) || 0),
),
},
})
}
/>
</label>
<label className="mo-inline">
offset
<input
type="number"
aria-label="Field offset"
min={0}
max={31}
value={operand.field.offset}
onChange={(e) =>
updateOperand(id, i, {
field: {
...operand.field,
offset: Math.max(
0,
Math.round(Number(e.target.value) || 0),
),
},
})
}
/>
</label>
<label className="mo-inline">
bits
<input
type="number"
aria-label="Field width"
min={1}
max={32}
value={operand.field.width}
onChange={(e) =>
updateOperand(id, i, {
field: {
...operand.field,
width: Math.min(
32,
Math.max(1, Math.round(Number(e.target.value) || 1)),
),
},
})
}
/>
</label>
<Button
variant="icon"
onClick={() => removeOperand(id, i)}
title="Remove operand"
aria-label="Remove operand"
>
</Button>
</div>
))
)}
</section>
<section className="instr-section">
<header className="instr-section-head">
<h3>Behavior (micro-ops)</h3>
<Button
onClick={() => addMicroOp(id, defaultMicroOp('move', ctx))}
data-testid="add-microop"
>
Add micro-op
</Button>
</header>
{instruction.microOps.length === 0 ? (
<p className="isa-hint">
No micro-ops yet - the instruction executes as a no-op. Build its
behavior as a sequence of steps.
</p>
) : (
instruction.microOps.map((op, i) => (
<MicroOpRow
key={i}
index={i}
count={instruction.microOps.length}
op={op}
ctx={ctx}
onChange={(next) => updateMicroOp(id, i, next)}
onRemove={() => removeMicroOp(id, i)}
onMove={(delta) => moveMicroOp(id, i, delta)}
/>
))
)}
</section>
<Button
className="instr-delete"
onClick={() => removeInstruction(id)}
data-testid="delete-instruction"
>
Delete instruction
</Button>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
import { useMemo } from 'react'
import { useGraphStore } from '../editor/graphStore'
import { compileGraph } from '../machine/compileGraph'
import { Button } from '../ui/Button'
import './isa.css'
import { InstructionEditor } from './InstructionEditor'
import { useIsaStore } from './isaStore'
import { validateIsa } from './validateIsa'
/**
* The Instruction Set view: instruction list + ISA settings on the left,
* the selected instruction's editor on the right, and a problems strip
* combining machine-model and ISA diagnostics at the bottom.
*/
export function IsaDesigner() {
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const isa = useIsaStore((s) => s.isa)
const selectedId = useIsaStore((s) => s.selectedId)
const select = useIsaStore((s) => s.select)
const setIsaMeta = useIsaStore((s) => s.setIsaMeta)
const addInstruction = useIsaStore((s) => s.addInstruction)
const compiled = useMemo(() => compileGraph(nodes, edges), [nodes, edges])
const isaDiagnostics = useMemo(
() => validateIsa(isa, compiled.model),
[isa, compiled.model],
)
const selected = isa.instructions.find((i) => i.id === selectedId) ?? null
const modelErrors = compiled.diagnostics.filter(
(d) => d.severity === 'error',
).length
const problems = [
...compiled.diagnostics.map((d) => ({ ...d, source: 'design' as const })),
...isaDiagnostics.map((d) => ({ ...d, source: 'isa' as const })),
].sort((a, b) =>
a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1,
)
return (
<div className="isa-designer" data-testid="isa-designer">
<div className="isa-main">
<aside className="isa-list">
<div className="isa-meta">
<label className="isa-field">
<span>ISA description</span>
<input
type="text"
value={isa.description}
placeholder="e.g. TINY-8 instruction set"
onChange={(e) => setIsaMeta({ description: e.target.value })}
/>
</label>
<div className="isa-opcode-field">
<span className="isa-field-caption">
Opcode field (word 0 of every instruction)
</span>
<label className="mo-inline">
offset
<input
type="number"
aria-label="Opcode offset"
min={0}
max={31}
value={isa.opcodeField.offset}
onChange={(e) =>
setIsaMeta({
opcodeField: {
...isa.opcodeField,
offset: Math.max(
0,
Math.round(Number(e.target.value) || 0),
),
},
})
}
/>
</label>
<label className="mo-inline">
bits
<input
type="number"
aria-label="Opcode width"
min={1}
max={32}
value={isa.opcodeField.width}
onChange={(e) =>
setIsaMeta({
opcodeField: {
...isa.opcodeField,
width: Math.min(
32,
Math.max(1, Math.round(Number(e.target.value) || 1)),
),
},
})
}
/>
</label>
</div>
</div>
<div className="isa-instr-list" data-testid="instruction-list">
{isa.instructions.map((instr) => (
<button
key={instr.id}
type="button"
className={
instr.id === selectedId
? 'isa-instr-item isa-instr-item-active'
: 'isa-instr-item'
}
onClick={() => select(instr.id)}
>
<span className="isa-instr-mnemonic">{instr.mnemonic}</span>
<span className="isa-instr-meta">
0x
{instr.opcode
.toString(16)
.toUpperCase()
.padStart(2, '0')} · {instr.words}w ·{' '}
{instr.operands.length} op
</span>
</button>
))}
{isa.instructions.length === 0 ? (
<p className="isa-hint">
No instructions yet. Add your first one to start defining the
assembly language for this CPU.
</p>
) : null}
</div>
<Button
variant="solid"
onClick={addInstruction}
data-testid="add-instruction"
>
Add instruction
</Button>
</aside>
<div className="isa-detail">
{selected ? (
<InstructionEditor instruction={selected} model={compiled.model} />
) : (
<div className="isa-empty">
<p>
Select an instruction on the left - or add one - to edit its
encoding, operands, and behavior.
</p>
<p className="isa-hint">
{compiled.model
? `Machine model ready: ${compiled.model.banks.length} bank(s), ${compiled.model.memories.length} memory(ies), ${compiled.model.flags.length} flag(s).`
: `The architecture design has ${modelErrors} error(s) - behavior editors will offer component pickers once the design compiles.`}
</p>
</div>
)}
</div>
</div>
<section className="isa-problems" data-testid="isa-problems">
<h3>
Problems{' '}
<span className="isa-problems-count">
{problems.length === 0 ? 'none' : problems.length}
</span>
</h3>
<ul>
{problems.map((problem, i) => (
<li key={i} className={`problem problem-${problem.severity}`}>
<span className="problem-source">[{problem.source}]</span>{' '}
{problem.message}
</li>
))}
</ul>
</section>
</div>
)
}
+567
View File
@@ -0,0 +1,567 @@
import type { Dst, MicroOp, Src, SrcIndex } from '../machine/microOps'
import { TEMP_COUNT } from '../machine/microOps'
import { Button } from '../ui/Button'
import { ChevronDownIcon, ChevronUpIcon } from '../ui/icons'
import { defaultMicroOp, type MicroOpEditorCtx } from './microOpDefaults'
type Ctx = MicroOpEditorCtx
/** Select that always shows the current value, even if it's not an option. */
function NameSelect({
value,
options,
onChange,
label,
}: {
value: string
options: string[]
onChange: (value: string) => void
label: string
}) {
const all = options.includes(value) ? options : [value, ...options]
if (all.length === 0 || (all.length === 1 && all[0] === '')) {
return (
<input
type="text"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<select
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
{all.map((option) => (
<option key={option} value={option}>
{option === '' ? '-' : option}
</option>
))}
</select>
)
}
function IndexEditor({
value,
onChange,
ctx,
}: {
value: SrcIndex
onChange: (value: SrcIndex) => void
ctx: Ctx
}) {
return (
<span className="mo-index">
<select
aria-label="Index source"
value={value.kind}
onChange={(e) =>
onChange(
e.target.value === 'literal'
? { kind: 'literal', value: 0 }
: { kind: 'operand', field: ctx.operandNames[0] ?? '' },
)
}
>
<option value="literal">#</option>
<option value="operand">operand</option>
</select>
{value.kind === 'literal' ? (
<input
type="number"
aria-label="Register index"
min={0}
value={value.value}
onChange={(e) =>
onChange({
kind: 'literal',
value: Math.max(0, Math.round(Number(e.target.value) || 0)),
})
}
/>
) : (
<NameSelect
label="Index operand"
value={value.field}
options={ctx.operandNames}
onChange={(field) => onChange({ kind: 'operand', field })}
/>
)}
</span>
)
}
const SRC_KINDS: Src['kind'][] = [
'const',
'reg',
'pc',
'temp',
'operand',
'flag',
]
function defaultSrc(kind: Src['kind'], ctx: Ctx): Src {
switch (kind) {
case 'const':
return { kind: 'const', value: 0 }
case 'reg':
return {
kind: 'reg',
bank: ctx.model?.banks[0]?.name ?? '',
index: { kind: 'literal', value: 0 },
}
case 'pc':
return { kind: 'pc' }
case 'temp':
return { kind: 'temp', index: 0 }
case 'operand':
return { kind: 'operand', field: ctx.operandNames[0] ?? '' }
case 'flag':
return { kind: 'flag', name: ctx.model?.flags[0] ?? '' }
}
}
export function SrcEditor({
label,
value,
onChange,
ctx,
}: {
label: string
value: Src
onChange: (value: Src) => void
ctx: Ctx
}) {
return (
<span className="mo-value">
<span className="mo-value-label">{label}</span>
<select
aria-label={`${label} kind`}
value={value.kind}
onChange={(e) =>
onChange(defaultSrc(e.target.value as Src['kind'], ctx))
}
>
{SRC_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
{value.kind === 'const' ? (
<input
type="number"
aria-label={`${label} constant`}
min={0}
value={value.value}
onChange={(e) =>
onChange({
kind: 'const',
value: Math.max(0, Math.round(Number(e.target.value) || 0)),
})
}
/>
) : null}
{value.kind === 'reg' ? (
<>
<NameSelect
label={`${label} bank`}
value={value.bank}
options={ctx.model?.banks.map((b) => b.name) ?? []}
onChange={(bank) => onChange({ ...value, bank })}
/>
<IndexEditor
value={value.index}
onChange={(index) => onChange({ ...value, index })}
ctx={ctx}
/>
</>
) : null}
{value.kind === 'temp' ? (
<select
aria-label={`${label} temp`}
value={value.index}
onChange={(e) =>
onChange({ kind: 'temp', index: Number(e.target.value) })
}
>
{Array.from({ length: TEMP_COUNT }, (_, i) => (
<option key={i} value={i}>
T{i}
</option>
))}
</select>
) : null}
{value.kind === 'operand' ? (
<NameSelect
label={`${label} operand`}
value={value.field}
options={ctx.operandNames}
onChange={(field) => onChange({ kind: 'operand', field })}
/>
) : null}
{value.kind === 'flag' ? (
<NameSelect
label={`${label} flag`}
value={value.name}
options={ctx.model?.flags ?? []}
onChange={(name) => onChange({ kind: 'flag', name })}
/>
) : null}
</span>
)
}
const DST_KINDS: Dst['kind'][] = ['reg', 'pc', 'temp']
function defaultDst(kind: Dst['kind'], ctx: Ctx): Dst {
switch (kind) {
case 'reg':
return {
kind: 'reg',
bank: ctx.model?.banks[0]?.name ?? '',
index: { kind: 'literal', value: 0 },
}
case 'pc':
return { kind: 'pc' }
case 'temp':
return { kind: 'temp', index: 0 }
}
}
export function DstEditor({
label,
value,
onChange,
ctx,
}: {
label: string
value: Dst
onChange: (value: Dst) => void
ctx: Ctx
}) {
return (
<span className="mo-value">
<span className="mo-value-label">{label}</span>
<select
aria-label={`${label} kind`}
value={value.kind}
onChange={(e) =>
onChange(defaultDst(e.target.value as Dst['kind'], ctx))
}
>
{DST_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
{value.kind === 'reg' ? (
<>
<NameSelect
label={`${label} bank`}
value={value.bank}
options={ctx.model?.banks.map((b) => b.name) ?? []}
onChange={(bank) => onChange({ ...value, bank })}
/>
<IndexEditor
value={value.index}
onChange={(index) => onChange({ ...value, index })}
ctx={ctx}
/>
</>
) : null}
{value.kind === 'temp' ? (
<select
aria-label={`${label} temp`}
value={value.index}
onChange={(e) =>
onChange({ kind: 'temp', index: Number(e.target.value) })
}
>
{Array.from({ length: TEMP_COUNT }, (_, i) => (
<option key={i} value={i}>
T{i}
</option>
))}
</select>
) : null}
</span>
)
}
const OP_KINDS: MicroOp['op'][] = [
'move',
'alu',
'load',
'store',
'setFlag',
'jump',
'branch',
'halt',
]
const ALU_FNS = ['add', 'sub', 'and', 'or', 'xor', 'not', 'shl', 'shr'] as const
const UNARY_FNS = new Set(['not', 'shl', 'shr'])
export function MicroOpRow({
index,
count,
op,
onChange,
onRemove,
onMove,
ctx,
}: {
index: number
count: number
op: MicroOp
onChange: (op: MicroOp) => void
onRemove: () => void
onMove: (delta: -1 | 1) => void
ctx: Ctx
}) {
return (
<div className="mo-row" data-testid="microop-row">
<span className="mo-step">{index + 1}</span>
<select
aria-label="Micro-op type"
className="mo-op"
value={op.op}
onChange={(e) =>
onChange(defaultMicroOp(e.target.value as MicroOp['op'], ctx))
}
>
{OP_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
<span className="mo-fields">
{op.op === 'move' ? (
<>
<DstEditor
label="dst"
value={op.dst}
onChange={(dst) => onChange({ ...op, dst })}
ctx={ctx}
/>
<SrcEditor
label="src"
value={op.src}
onChange={(src) => onChange({ ...op, src })}
ctx={ctx}
/>
</>
) : null}
{op.op === 'alu' ? (
<>
<select
aria-label="ALU function"
value={op.fn}
onChange={(e) =>
onChange({
...op,
fn: e.target.value as (typeof ALU_FNS)[number],
})
}
>
{ALU_FNS.map((fn) => (
<option key={fn} value={fn}>
{fn}
</option>
))}
</select>
<label className="mo-inline">
w
<input
type="number"
aria-label="ALU width"
min={1}
max={32}
value={op.width}
onChange={(e) =>
onChange({
...op,
width: Math.min(
32,
Math.max(1, Math.round(Number(e.target.value) || 8)),
),
})
}
/>
</label>
<DstEditor
label="dst"
value={op.dst}
onChange={(dst) => onChange({ ...op, dst })}
ctx={ctx}
/>
<SrcEditor
label="a"
value={op.a}
onChange={(a) => onChange({ ...op, a })}
ctx={ctx}
/>
{!UNARY_FNS.has(op.fn) ? (
<SrcEditor
label="b"
value={op.b ?? { kind: 'const', value: 0 }}
onChange={(b) => onChange({ ...op, b })}
ctx={ctx}
/>
) : null}
<label className="mo-inline">
<input
type="checkbox"
checked={op.setFlags}
onChange={(e) =>
onChange({ ...op, setFlags: e.target.checked })
}
/>
flags
</label>
</>
) : null}
{op.op === 'load' ? (
<>
<NameSelect
label="Memory"
value={op.memory}
options={ctx.model?.memories.map((m) => m.name) ?? []}
onChange={(memory) => onChange({ ...op, memory })}
/>
<DstEditor
label="dst"
value={op.dst}
onChange={(dst) => onChange({ ...op, dst })}
ctx={ctx}
/>
<SrcEditor
label="addr"
value={op.addr}
onChange={(addr) => onChange({ ...op, addr })}
ctx={ctx}
/>
</>
) : null}
{op.op === 'store' ? (
<>
<NameSelect
label="Memory"
value={op.memory}
options={ctx.model?.memories.map((m) => m.name) ?? []}
onChange={(memory) => onChange({ ...op, memory })}
/>
<SrcEditor
label="addr"
value={op.addr}
onChange={(addr) => onChange({ ...op, addr })}
ctx={ctx}
/>
<SrcEditor
label="src"
value={op.src}
onChange={(src) => onChange({ ...op, src })}
ctx={ctx}
/>
</>
) : null}
{op.op === 'setFlag' ? (
<>
<NameSelect
label="Flag"
value={op.name}
options={ctx.model?.flags ?? []}
onChange={(name) => onChange({ ...op, name })}
/>
<SrcEditor
label="src"
value={op.src}
onChange={(src) => onChange({ ...op, src })}
ctx={ctx}
/>
</>
) : null}
{op.op === 'jump' ? (
<SrcEditor
label="target"
value={op.target}
onChange={(target) => onChange({ ...op, target })}
ctx={ctx}
/>
) : null}
{op.op === 'branch' ? (
<>
<NameSelect
label="Flag"
value={op.flag}
options={ctx.model?.flags ?? []}
onChange={(flag) => onChange({ ...op, flag })}
/>
<select
aria-label="Branch condition"
value={op.ifSet ? 'set' : 'clear'}
onChange={(e) =>
onChange({ ...op, ifSet: e.target.value === 'set' })
}
>
<option value="set">if set</option>
<option value="clear">if clear</option>
</select>
<SrcEditor
label="target"
value={op.target}
onChange={(target) => onChange({ ...op, target })}
ctx={ctx}
/>
</>
) : null}
{op.op === 'halt' ? (
<span className="mo-halt">stops the machine</span>
) : null}
</span>
<span className="mo-actions">
<Button
variant="icon"
onClick={() => onMove(-1)}
disabled={index === 0}
title="Move up"
aria-label="Move micro-op up"
>
<ChevronUpIcon />
</Button>
<Button
variant="icon"
onClick={() => onMove(1)}
disabled={index === count - 1}
title="Move down"
aria-label="Move micro-op down"
>
<ChevronDownIcon />
</Button>
<Button
variant="icon"
onClick={onRemove}
title="Remove micro-op"
aria-label="Remove micro-op"
>
</Button>
</span>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import {
extractField,
fieldFitsWord,
fieldMaxValue,
fieldsOverlap,
packField,
} from './encoding'
describe('fieldMaxValue', () => {
it('computes the max for widths incl. 32', () => {
expect(fieldMaxValue({ word: 0, offset: 0, width: 1 })).toBe(1)
expect(fieldMaxValue({ word: 0, offset: 0, width: 8 })).toBe(255)
expect(fieldMaxValue({ word: 0, offset: 0, width: 32 })).toBe(0xffffffff)
})
})
describe('fieldsOverlap', () => {
it('detects overlaps within a word only', () => {
const a = { word: 0, offset: 0, width: 4 }
expect(fieldsOverlap(a, { word: 0, offset: 3, width: 2 })).toBe(true)
expect(fieldsOverlap(a, { word: 0, offset: 4, width: 4 })).toBe(false)
expect(fieldsOverlap(a, { word: 1, offset: 0, width: 4 })).toBe(false)
})
})
describe('fieldFitsWord', () => {
it('checks offset + width against the word width', () => {
expect(fieldFitsWord({ word: 0, offset: 4, width: 4 }, 8)).toBe(true)
expect(fieldFitsWord({ word: 0, offset: 5, width: 4 }, 8)).toBe(false)
})
})
describe('packField / extractField', () => {
it('round-trips values in nibble fields', () => {
let words = [0, 0]
words = packField(words, { word: 0, offset: 0, width: 8 }, 0x21)
words = packField(words, { word: 1, offset: 4, width: 4 }, 0x3)
words = packField(words, { word: 1, offset: 0, width: 4 }, 0x5)
expect(words).toEqual([0x21, 0x35])
expect(extractField(words, { word: 1, offset: 4, width: 4 })).toBe(3)
expect(extractField(words, { word: 1, offset: 0, width: 4 })).toBe(5)
})
it('overwrites only the field bits', () => {
const words = packField([0xff], { word: 0, offset: 2, width: 3 }, 0)
expect(words).toEqual([0b11100011])
})
it('does not mutate its input', () => {
const original = [0]
packField(original, { word: 0, offset: 0, width: 4 }, 7)
expect(original).toEqual([0])
})
it('rejects values that do not fit', () => {
expect(() => packField([0], { word: 0, offset: 0, width: 4 }, 16)).toThrow(
RangeError,
)
expect(() => packField([0], { word: 0, offset: 0, width: 4 }, -1)).toThrow(
RangeError,
)
})
it('rejects out-of-instruction words', () => {
expect(() => packField([0], { word: 1, offset: 0, width: 4 }, 1)).toThrow(
RangeError,
)
expect(() => extractField([0], { word: 2, offset: 0, width: 4 })).toThrow(
RangeError,
)
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { BitField } from './isaModel'
/** Largest value a field can hold. */
export function fieldMaxValue(field: BitField): number {
return field.width >= 32 ? 0xffffffff : (1 << field.width) - 1
}
/** Do two bit fields overlap (same word, intersecting ranges)? */
export function fieldsOverlap(a: BitField, b: BitField): boolean {
return (
a.word === b.word &&
a.offset < b.offset + b.width &&
b.offset < a.offset + a.width
)
}
/** Does the field fit entirely inside a word of `wordWidth` bits? */
export function fieldFitsWord(field: BitField, wordWidth: number): boolean {
return field.offset + field.width <= wordWidth
}
/**
* Write `value` into `field` of an instruction's words (returns a copy).
* Throws RangeError if the value does not fit the field or the field's word
* is out of range.
*/
export function packField(
words: readonly number[],
field: BitField,
value: number,
): number[] {
if (value < 0 || value > fieldMaxValue(field)) {
throw new RangeError(
`value ${value} does not fit in a ${field.width}-bit field`,
)
}
if (field.word >= words.length) {
throw new RangeError(`field word ${field.word} outside the instruction`)
}
const result = [...words]
const mask = fieldMaxValue(field)
const current = result[field.word] ?? 0
result[field.word] =
((current & ~(mask << field.offset)) | (value << field.offset)) >>> 0
return result
}
/** Read `field` from an instruction's words. */
export function extractField(
words: readonly number[],
field: BitField,
): number {
const word = words[field.word]
if (word === undefined) {
throw new RangeError(`field word ${field.word} outside the instruction`)
}
return (word >>> field.offset) & fieldMaxValue(field)
}
+376
View File
@@ -0,0 +1,376 @@
.isa-designer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
.isa-main {
flex: 1;
min-height: 0;
display: flex;
}
/* --- Left column: settings + instruction list --- */
.isa-list {
width: 260px;
flex: none;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-right: 1px solid var(--border);
background: var(--bg-panel);
overflow-y: auto;
}
.isa-meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.isa-opcode-field {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.isa-field-caption {
flex-basis: 100%;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.isa-instr-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.isa-instr-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 10px;
font: inherit;
text-align: left;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
color: var(--text);
}
.isa-instr-item:hover {
background: var(--hover-bg);
}
.isa-instr-item-active {
border-color: var(--accent);
background: var(--accent-bg);
}
.isa-instr-mnemonic {
font-family: var(--mono);
font-weight: 700;
color: var(--text-h);
}
.isa-instr-meta {
font-size: 11px;
font-family: var(--mono);
}
/* --- Detail editor --- */
.isa-detail {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 16px 20px;
}
.isa-empty {
max-width: 52ch;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text);
}
.instr-editor {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 900px;
}
.instr-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.instr-hex {
font-family: var(--mono);
font-size: 13px;
color: var(--text);
padding-bottom: 8px;
}
.isa-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.isa-field > span {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.isa-field input[type='text'],
.isa-field textarea {
min-width: 180px;
}
.isa-field input[type='number'] {
width: 80px;
}
.instr-flags {
display: flex;
gap: 14px;
}
.instr-section {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.instr-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.instr-section-head h3 {
margin: 0;
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.isa-hint {
font-size: 12.5px;
color: var(--text);
opacity: 0.8;
}
.instr-delete {
align-self: flex-start;
}
/* --- Operand rows --- */
.operand-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.operand-name {
width: 90px;
font-family: var(--mono);
}
/* --- Micro-op rows --- */
.mo-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.mo-step {
font-family: var(--mono);
font-size: 11px;
color: var(--text);
padding-top: 7px;
width: 16px;
text-align: right;
flex: none;
}
.mo-op {
font-family: var(--mono);
font-weight: 600;
flex: none;
}
.mo-fields {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mo-value {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border: 1px dashed var(--border);
border-radius: 6px;
}
.mo-value-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
color: var(--accent);
}
.mo-index {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mo-inline {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text);
}
.mo-halt {
font-size: 12px;
font-style: italic;
color: var(--text);
}
.mo-actions {
display: flex;
flex: none;
}
/* Shared input styling within the designer */
.isa-designer input,
.isa-designer select,
.isa-designer textarea {
font: 12.5px var(--sans);
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
}
.isa-designer input[type='number'] {
width: 64px;
}
.isa-designer input[type='checkbox'] {
width: auto;
}
.isa-designer input:focus-visible,
.isa-designer select:focus-visible,
.isa-designer textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
/* --- Problems strip --- */
.isa-problems {
flex: none;
max-height: 160px;
overflow-y: auto;
border-top: 1px solid var(--border);
background: var(--bg-panel);
padding: 8px 14px;
}
.isa-problems h3 {
margin: 0 0 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text);
}
.isa-problems-count {
color: var(--accent);
}
.isa-problems ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
}
.problem {
font-size: 12.5px;
color: var(--text);
}
.problem-error {
color: #d64545;
}
:root[data-theme='dark'] .problem-error {
color: #ff8a8a;
}
.problem-source {
font-family: var(--mono);
font-size: 11px;
opacity: 0.7;
}
/* --- Toolbar view tabs --- */
.toolbar-tabs {
display: flex;
align-items: center;
gap: 2px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 2px;
}
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod'
import { identifierSchema } from '../machine/machineModel'
import { microOpSequenceSchema } from '../machine/microOps'
export const MAX_INSTRUCTION_WORDS = 4
export const MAX_OPERANDS = 3
/** A bit range inside one word of an instruction. offset 0 = LSB. */
export const bitFieldSchema = z.object({
word: z
.number()
.int()
.min(0)
.max(MAX_INSTRUCTION_WORDS - 1),
offset: z.number().int().min(0).max(31),
width: z.number().int().min(1).max(32),
})
export const operandSlotSchema = z.object({
/** Field name referenced by micro-ops (kind "operand") and the assembler. */
name: identifierSchema,
/**
* register - matches a register of `bank` (encodes its index)
* immediate - literal value, written as #n in assembly
* address - memory address or label, written as n or a label
*/
kind: z.enum(['register', 'immediate', 'address']),
/** Register operands: which bank the operand selects from. */
bank: identifierSchema.optional(),
field: bitFieldSchema,
})
export const instructionSchema = z.object({
/** Stable id for UI selection and diagnostics. */
id: z.string().min(1),
mnemonic: identifierSchema,
/** Value of the shared opcode field identifying this instruction. */
opcode: z.number().int().min(0),
/** Total instruction length in program-memory words. */
words: z.number().int().min(1).max(MAX_INSTRUCTION_WORDS),
operands: z.array(operandSlotSchema).max(MAX_OPERANDS).default([]),
/** Documentation: which flags this instruction affects. */
flagsAffected: z.array(identifierSchema).default([]),
doc: z.string().default(''),
/** Execution behavior (see docs/execution-model.md §4). */
microOps: microOpSequenceSchema.default([]),
})
export const isaSchema = z.object({
description: z.string().default(''),
/** Opcode position shared by all instructions; always in word 0. */
opcodeField: z.object({
offset: z.number().int().min(0).max(31),
width: z.number().int().min(1).max(32),
}),
instructions: z.array(instructionSchema).default([]),
})
export type BitField = z.infer<typeof bitFieldSchema>
export type OperandSlot = z.infer<typeof operandSlotSchema>
export type Instruction = z.infer<typeof instructionSchema>
export type IsaDefinition = z.infer<typeof isaSchema>
export function defaultIsa(): IsaDefinition {
return {
description: '',
opcodeField: { offset: 0, width: 8 },
instructions: [],
}
}
export function newInstruction(existing: Instruction[]): Instruction {
const used = new Set(existing.map((i) => i.opcode))
let opcode = 0
while (used.has(opcode)) opcode++
const names = new Set(existing.map((i) => i.mnemonic.toLowerCase()))
let n = existing.length + 1
let mnemonic = `OP${n}`
while (names.has(mnemonic.toLowerCase())) mnemonic = `OP${++n}`
return {
id: crypto.randomUUID(),
mnemonic,
opcode,
words: 1,
operands: [],
flagsAffected: [],
doc: '',
microOps: [],
}
}
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { defaultIsa } from './isaModel'
import { useIsaStore } from './isaStore'
beforeEach(() => {
useIsaStore.setState({ isa: defaultIsa(), selectedId: null })
})
const state = () => useIsaStore.getState()
describe('instruction management', () => {
it('adds instructions with unique mnemonics and opcodes, selecting them', () => {
state().addInstruction()
state().addInstruction()
const [a, b] = state().isa.instructions
expect(a?.mnemonic).not.toBe(b?.mnemonic)
expect(a?.opcode).not.toBe(b?.opcode)
expect(state().selectedId).toBe(b?.id)
})
it('updates without clobbering the id', () => {
state().addInstruction()
const id = state().isa.instructions[0]?.id ?? ''
state().updateInstruction(id, { mnemonic: 'LDA', opcode: 7 })
const instr = state().isa.instructions[0]
expect(instr?.id).toBe(id)
expect(instr?.mnemonic).toBe('LDA')
expect(instr?.opcode).toBe(7)
})
it('removes instructions and clears their selection', () => {
state().addInstruction()
const id = state().isa.instructions[0]?.id ?? ''
state().removeInstruction(id)
expect(state().isa.instructions).toHaveLength(0)
expect(state().selectedId).toBeNull()
})
})
describe('operand management', () => {
it('adds operands with unique default names', () => {
state().addInstruction()
const id = state().isa.instructions[0]?.id ?? ''
state().addOperand(id)
state().addOperand(id)
const names = state().isa.instructions[0]?.operands.map((o) => o.name)
expect(new Set(names).size).toBe(2)
})
it('updates and removes operands by index', () => {
state().addInstruction()
const id = state().isa.instructions[0]?.id ?? ''
state().addOperand(id)
state().updateOperand(id, 0, { name: 'imm', kind: 'immediate' })
expect(state().isa.instructions[0]?.operands[0]?.name).toBe('imm')
state().removeOperand(id, 0)
expect(state().isa.instructions[0]?.operands).toHaveLength(0)
})
})
describe('micro-op management', () => {
it('adds, reorders, and removes micro-ops', () => {
state().addInstruction()
const id = state().isa.instructions[0]?.id ?? ''
state().addMicroOp(id, { op: 'halt' })
state().addMicroOp(id, { op: 'jump', target: { kind: 'const', value: 0 } })
expect(state().isa.instructions[0]?.microOps.map((o) => o.op)).toEqual([
'halt',
'jump',
])
state().moveMicroOp(id, 1, -1)
expect(state().isa.instructions[0]?.microOps.map((o) => o.op)).toEqual([
'jump',
'halt',
])
// Out-of-range moves are no-ops
state().moveMicroOp(id, 0, -1)
expect(state().isa.instructions[0]?.microOps.map((o) => o.op)).toEqual([
'jump',
'halt',
])
state().removeMicroOp(id, 0)
expect(state().isa.instructions[0]?.microOps.map((o) => o.op)).toEqual([
'halt',
])
})
})
+188
View File
@@ -0,0 +1,188 @@
import { create } from 'zustand'
import { checkpoint } from '../model/history'
import type { MicroOp } from '../machine/microOps'
import {
defaultIsa,
newInstruction,
type Instruction,
type IsaDefinition,
type OperandSlot,
} from './isaModel'
export interface IsaState {
isa: IsaDefinition
/** Currently selected instruction (UI state, not persisted). */
selectedId: string | null
select: (id: string | null) => void
setIsaMeta: (
patch: Partial<Pick<IsaDefinition, 'description' | 'opcodeField'>>,
) => void
addInstruction: () => void
updateInstruction: (
id: string,
patch: Partial<Omit<Instruction, 'id'>>,
) => void
removeInstruction: (id: string) => void
addOperand: (instructionId: string) => void
updateOperand: (
instructionId: string,
index: number,
patch: Partial<OperandSlot>,
) => void
removeOperand: (instructionId: string, index: number) => void
addMicroOp: (instructionId: string, op: MicroOp) => void
updateMicroOp: (instructionId: string, index: number, op: MicroOp) => void
removeMicroOp: (instructionId: string, index: number) => void
moveMicroOp: (instructionId: string, index: number, delta: -1 | 1) => void
/** Replace the whole ISA (project load). */
loadIsa: (isa: IsaDefinition) => void
resetIsa: () => void
}
function patchInstruction(
isa: IsaDefinition,
id: string,
update: (instr: Instruction) => Instruction,
): IsaDefinition {
return {
...isa,
instructions: isa.instructions.map((instr) =>
instr.id === id ? update(instr) : instr,
),
}
}
export const useIsaStore = create<IsaState>()((set, get) => ({
isa: defaultIsa(),
selectedId: null,
select: (id) => set({ selectedId: id }),
setIsaMeta: (patch) => {
checkpoint('isa:meta')
set({ isa: { ...get().isa, ...patch } })
},
addInstruction: () => {
checkpoint()
const isa = get().isa
const instr = newInstruction(isa.instructions)
set({
isa: { ...isa, instructions: [...isa.instructions, instr] },
selectedId: instr.id,
})
},
updateInstruction: (id, patch) => {
checkpoint(`isa:instr:${id}`)
set({
isa: patchInstruction(get().isa, id, (instr) => ({
...instr,
...patch,
id: instr.id,
})),
})
},
removeInstruction: (id) => {
checkpoint()
const isa = get().isa
set({
isa: {
...isa,
instructions: isa.instructions.filter((i) => i.id !== id),
},
selectedId: get().selectedId === id ? null : get().selectedId,
})
},
addOperand: (instructionId) => {
checkpoint()
set({
isa: patchInstruction(get().isa, instructionId, (instr) => {
const names = new Set(instr.operands.map((o) => o.name))
let n = instr.operands.length
let name = `op${n}`
while (names.has(name)) name = `op${++n}`
const operand: OperandSlot = {
name,
kind: 'immediate',
field: { word: Math.min(1, instr.words - 1), offset: 0, width: 8 },
}
return { ...instr, operands: [...instr.operands, operand] }
}),
})
},
updateOperand: (instructionId, index, patch) => {
checkpoint(`isa:operand:${instructionId}:${index}`)
set({
isa: patchInstruction(get().isa, instructionId, (instr) => ({
...instr,
operands: instr.operands.map((operand, i) =>
i === index ? { ...operand, ...patch } : operand,
),
})),
})
},
removeOperand: (instructionId, index) => {
checkpoint()
set({
isa: patchInstruction(get().isa, instructionId, (instr) => ({
...instr,
operands: instr.operands.filter((_, i) => i !== index),
})),
})
},
addMicroOp: (instructionId, op) => {
checkpoint()
set({
isa: patchInstruction(get().isa, instructionId, (instr) => ({
...instr,
microOps: [...instr.microOps, op],
})),
})
},
updateMicroOp: (instructionId, index, op) => {
checkpoint(`isa:microop:${instructionId}:${index}`)
set({
isa: patchInstruction(get().isa, instructionId, (instr) => ({
...instr,
microOps: instr.microOps.map((existing, i) =>
i === index ? op : existing,
),
})),
})
},
removeMicroOp: (instructionId, index) => {
checkpoint()
set({
isa: patchInstruction(get().isa, instructionId, (instr) => ({
...instr,
microOps: instr.microOps.filter((_, i) => i !== index),
})),
})
},
moveMicroOp: (instructionId, index, delta) => {
checkpoint()
set({
isa: patchInstruction(get().isa, instructionId, (instr) => {
const target = index + delta
if (target < 0 || target >= instr.microOps.length) return instr
const microOps = [...instr.microOps]
const [moved] = microOps.splice(index, 1)
if (moved) microOps.splice(target, 0, moved)
return { ...instr, microOps }
}),
})
},
loadIsa: (isa) => set({ isa, selectedId: null }),
resetIsa: () => set({ isa: defaultIsa(), selectedId: null }),
}))
+50
View File
@@ -0,0 +1,50 @@
import type { MachineModel } from '../machine/machineModel'
import type { Dst, MicroOp, Src } from '../machine/microOps'
/** Context the editors need: the compiled model and operand field names. */
export interface MicroOpEditorCtx {
model: MachineModel | null
operandNames: string[]
}
/** A sensible blank micro-op of the given type, seeded from the model. */
export function defaultMicroOp(
op: MicroOp['op'],
ctx: MicroOpEditorCtx,
): MicroOp {
const src: Src = { kind: 'const', value: 0 }
const dst: Dst = { kind: 'temp', index: 0 }
const memory = ctx.model?.memories[0]?.name ?? ''
const flag = ctx.model?.flags[0] ?? ''
switch (op) {
case 'move':
return { op: 'move', dst, src }
case 'alu':
return {
op: 'alu',
fn: 'add',
width: ctx.model?.banks[0]?.width ?? 8,
dst,
a: src,
b: { kind: 'const', value: 0 },
setFlags: false,
}
case 'load':
return { op: 'load', memory, dst, addr: src }
case 'store':
return {
op: 'store',
memory,
addr: src,
src: { kind: 'const', value: 0 },
}
case 'setFlag':
return { op: 'setFlag', name: flag, src: { kind: 'const', value: 1 } }
case 'jump':
return { op: 'jump', target: src }
case 'branch':
return { op: 'branch', flag, ifSet: true, target: src }
case 'halt':
return { op: 'halt' }
}
}
+219
View File
@@ -0,0 +1,219 @@
import { describe, expect, it } from 'vitest'
import type { MachineModel } from '../machine/machineModel'
import type { Instruction, IsaDefinition } from './isaModel'
import { validateIsa } from './validateIsa'
const model: MachineModel = {
modelVersion: 1,
banks: [{ name: 'R', width: 8, count: 4 }],
memories: [{ name: 'MAIN', size: 256, width: 8 }],
flags: ['Z', 'N', 'C'],
pc: { width: 16 },
programMemory: 'MAIN',
}
let n = 0
function instr(patch: Partial<Instruction>): Instruction {
return {
id: `i${++n}`,
mnemonic: `OP${n}`,
opcode: n,
words: 2,
operands: [],
flagsAffected: [],
doc: '',
microOps: [{ op: 'halt' }],
...patch,
}
}
function isa(instructions: Instruction[]): IsaDefinition {
return { description: '', opcodeField: { offset: 0, width: 8 }, instructions }
}
const errors = (d: { severity: string; message: string }[]) =>
d.filter((x) => x.severity === 'error').map((x) => x.message)
const warnings = (d: { severity: string; message: string }[]) =>
d.filter((x) => x.severity === 'warning').map((x) => x.message)
describe('validateIsa', () => {
it('accepts a sound instruction', () => {
const good = instr({
mnemonic: 'LDI',
operands: [
{
name: 'rd',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 6, width: 2 },
},
{
name: 'imm',
kind: 'immediate',
field: { word: 1, offset: 0, width: 6 },
},
],
microOps: [
{
op: 'move',
dst: {
kind: 'reg',
bank: 'R',
index: { kind: 'operand', field: 'rd' },
},
src: { kind: 'operand', field: 'imm' },
},
],
})
expect(errors(validateIsa(isa([good]), model))).toEqual([])
})
it('flags duplicate mnemonics and opcode collisions', () => {
const a = instr({ mnemonic: 'NOP', opcode: 1 })
const b = instr({ mnemonic: 'nop', opcode: 1 })
const found = errors(validateIsa(isa([a, b]), model)).join()
expect(found).toContain('duplicate mnemonic')
expect(found).toContain('collides')
})
it('flags opcodes that do not fit the opcode field', () => {
const bad = instr({ opcode: 300 })
expect(errors(validateIsa(isa([bad]), model)).join()).toContain(
'does not fit',
)
})
it('flags operand fields that overlap the opcode or each other', () => {
const bad = instr({
operands: [
{
name: 'a',
kind: 'immediate',
field: { word: 0, offset: 4, width: 8 }, // overlaps opcode bits 0-7
},
{
name: 'b',
kind: 'immediate',
field: { word: 0, offset: 6, width: 4 }, // overlaps a
},
],
})
const found = errors(validateIsa(isa([bad]), model)).join()
expect(found).toContain('overlaps the opcode field')
expect(found).toContain('overlaps operand "a"')
})
it('flags operand fields outside the instruction or the word', () => {
const bad = instr({
words: 1,
operands: [
{
name: 'x',
kind: 'immediate',
field: { word: 1, offset: 0, width: 8 },
},
{
name: 'y',
kind: 'immediate',
field: { word: 0, offset: 6, width: 8 }, // 6+8 > 8-bit word
},
],
})
const found = errors(validateIsa(isa([bad]), model)).join()
expect(found).toContain('word 1, but the instruction is only 1')
expect(found).toContain('does not fit a 8-bit word')
})
it('flags register operands with missing or unknown banks', () => {
const noBank = instr({
operands: [
{
name: 'rd',
kind: 'register',
field: { word: 1, offset: 0, width: 2 },
},
],
})
const ghost = instr({
operands: [
{
name: 'rs',
kind: 'register',
bank: 'GHOST',
field: { word: 1, offset: 0, width: 2 },
},
],
})
expect(errors(validateIsa(isa([noBank]), model)).join()).toContain(
'no bank selected',
)
expect(errors(validateIsa(isa([ghost]), model)).join()).toContain(
'unknown bank',
)
})
it('warns when a register field cannot address the whole bank', () => {
const narrow = instr({
operands: [
{
name: 'rd',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 0, width: 1 }, // 2 of 4 registers
},
],
})
expect(warnings(validateIsa(isa([narrow]), model)).join()).toContain(
'not all are addressable',
)
})
it('flags micro-ops referencing unknown operands and components', () => {
const bad = instr({
microOps: [
{
op: 'move',
dst: {
kind: 'reg',
bank: 'GHOST',
index: { kind: 'operand', field: 'nope' },
},
src: { kind: 'flag', name: 'X' },
},
{
op: 'load',
memory: 'VOID',
dst: { kind: 'temp', index: 0 },
addr: { kind: 'const', value: 0 },
},
],
})
const found = errors(validateIsa(isa([bad]), model)).join()
expect(found).toContain('references operand "nope"')
expect(found).toContain('unknown register bank "GHOST"')
expect(found).toContain('unknown flag "X"')
expect(found).toContain('unknown memory "VOID"')
})
it('warns about no-op instructions and unknown flagsAffected', () => {
const empty = instr({ microOps: [], flagsAffected: ['Q'] })
const found = warnings(validateIsa(isa([empty]), model)).join()
expect(found).toContain('no micro-ops')
expect(found).toContain('lists "Q"')
})
it('skips model-dependent checks without a model', () => {
const needsModel = instr({
operands: [
{
name: 'rd',
kind: 'register',
bank: 'R',
field: { word: 1, offset: 0, width: 2 },
},
],
})
expect(errors(validateIsa(isa([needsModel]), null))).toEqual([])
})
})
+302
View File
@@ -0,0 +1,302 @@
import type { MachineModel } from '../machine/machineModel'
import type { MicroOp, Src } from '../machine/microOps'
import { fieldFitsWord, fieldMaxValue, fieldsOverlap } from './encoding'
import type { BitField, Instruction, IsaDefinition } from './isaModel'
export interface IsaDiagnostic {
severity: 'error' | 'warning'
message: string
instructionId?: string
}
/** Operand-field names a micro-op reads (kind "operand" sources/indexes). */
function operandRefsOf(op: MicroOp): string[] {
const refs: string[] = []
const fromSrc = (src: Src | undefined) => {
if (!src) return
if (src.kind === 'operand') refs.push(src.field)
if (src.kind === 'reg' && src.index.kind === 'operand') {
refs.push(src.index.field)
}
}
const fromDst = (
dst: { kind: string; index?: unknown } & Record<string, unknown>,
) => {
if (dst.kind === 'reg') {
const index = dst['index'] as { kind: string; field?: string }
if (index.kind === 'operand' && index.field) refs.push(index.field)
}
}
switch (op.op) {
case 'move':
fromSrc(op.src)
fromDst(op.dst)
break
case 'alu':
fromSrc(op.a)
fromSrc(op.b)
fromDst(op.dst)
break
case 'load':
fromSrc(op.addr)
fromDst(op.dst)
break
case 'store':
fromSrc(op.addr)
fromSrc(op.src)
break
case 'setFlag':
fromSrc(op.src)
break
case 'jump':
fromSrc(op.target)
break
case 'branch':
fromSrc(op.target)
break
case 'halt':
break
}
return refs
}
/** Names of banks/memories/flags a micro-op references. */
function componentRefsOf(op: MicroOp): {
banks: string[]
memories: string[]
flags: string[]
} {
const banks: string[] = []
const memories: string[] = []
const flags: string[] = []
const fromSrc = (src: Src | undefined) => {
if (!src) return
if (src.kind === 'reg') banks.push(src.bank)
if (src.kind === 'flag') flags.push(src.name)
}
switch (op.op) {
case 'move':
fromSrc(op.src)
if (op.dst.kind === 'reg') banks.push(op.dst.bank)
break
case 'alu':
fromSrc(op.a)
fromSrc(op.b)
if (op.dst.kind === 'reg') banks.push(op.dst.bank)
break
case 'load':
memories.push(op.memory)
fromSrc(op.addr)
if (op.dst.kind === 'reg') banks.push(op.dst.bank)
break
case 'store':
memories.push(op.memory)
fromSrc(op.addr)
fromSrc(op.src)
break
case 'setFlag':
flags.push(op.name)
fromSrc(op.src)
break
case 'jump':
fromSrc(op.target)
break
case 'branch':
flags.push(op.flag)
fromSrc(op.target)
break
case 'halt':
break
}
return { banks, memories, flags }
}
function validateInstruction(
instr: Instruction,
isa: IsaDefinition,
model: MachineModel | null,
push: (d: IsaDiagnostic) => void,
): void {
const tag = (severity: 'error' | 'warning', message: string) =>
push({
severity,
message: `${instr.mnemonic}: ${message}`,
instructionId: instr.id,
})
const wordWidth = model
? (model.memories.find((m) => m.name === model.programMemory)?.width ?? 32)
: 32
// Opcode value fits the shared field
const opcodeField: BitField = { word: 0, ...isa.opcodeField }
if (instr.opcode > fieldMaxValue(opcodeField)) {
tag(
'error',
`opcode ${instr.opcode} does not fit the ${isa.opcodeField.width}-bit opcode field`,
)
}
// Operand fields: inside the instruction, inside their word, no overlaps
const fields: { name: string; field: BitField }[] = [
{ name: 'opcode', field: opcodeField },
]
const seenNames = new Set<string>()
for (const operand of instr.operands) {
if (seenNames.has(operand.name.toLowerCase())) {
tag('error', `duplicate operand name "${operand.name}"`)
}
seenNames.add(operand.name.toLowerCase())
if (operand.field.word >= instr.words) {
tag(
'error',
`operand "${operand.name}" lives in word ${operand.field.word}, but the instruction is only ${instr.words} word(s) long`,
)
}
if (!fieldFitsWord(operand.field, wordWidth)) {
tag(
'error',
`operand "${operand.name}" (offset ${operand.field.offset}, width ${operand.field.width}) does not fit a ${wordWidth}-bit word`,
)
}
for (const prev of fields) {
if (fieldsOverlap(prev.field, operand.field)) {
tag(
'error',
`operand "${operand.name}" overlaps ${prev.name === 'opcode' ? 'the opcode field' : `operand "${prev.name}"`}`,
)
}
}
fields.push({ name: operand.name, field: operand.field })
// Kind-specific checks against the model
if (operand.kind === 'register') {
if (!operand.bank) {
tag('error', `register operand "${operand.name}" has no bank selected`)
} else if (model) {
const bank = model.banks.find((b) => b.name === operand.bank)
if (!bank) {
tag(
'error',
`register operand "${operand.name}" references unknown bank "${operand.bank}"`,
)
} else if (fieldMaxValue(operand.field) < bank.count - 1) {
tag(
'warning',
`operand "${operand.name}" is ${operand.field.width} bit(s) wide but bank "${operand.bank}" has ${bank.count} registers - not all are addressable`,
)
}
}
}
if (operand.kind === 'address' && model) {
const mem = model.memories.find((m) => m.name === model.programMemory)
if (mem && fieldMaxValue(operand.field) < mem.size - 1) {
tag(
'warning',
`address operand "${operand.name}" (${operand.field.width} bits) cannot reach all ${mem.size} words of "${mem.name}"`,
)
}
}
}
if (!fieldFitsWord(opcodeField, wordWidth)) {
tag(
'error',
`the opcode field (offset ${isa.opcodeField.offset}, width ${isa.opcodeField.width}) does not fit a ${wordWidth}-bit program-memory word`,
)
}
// Behavior checks
if (instr.microOps.length === 0) {
tag('warning', 'has no micro-ops - it will execute as a no-op')
}
const operandNames = new Set(instr.operands.map((o) => o.name))
for (const [i, op] of instr.microOps.entries()) {
for (const ref of operandRefsOf(op)) {
if (!operandNames.has(ref)) {
tag(
'error',
`micro-op #${i + 1} references operand "${ref}", which this instruction does not have`,
)
}
}
if (model) {
const refs = componentRefsOf(op)
for (const bank of refs.banks) {
if (!model.banks.some((b) => b.name === bank)) {
tag(
'error',
`micro-op #${i + 1} references unknown register bank "${bank}"`,
)
}
}
for (const memory of refs.memories) {
if (!model.memories.some((m) => m.name === memory)) {
tag(
'error',
`micro-op #${i + 1} references unknown memory "${memory}"`,
)
}
}
for (const flag of refs.flags) {
if (!model.flags.includes(flag)) {
tag('error', `micro-op #${i + 1} references unknown flag "${flag}"`)
}
}
}
}
// flagsAffected is documentation; verify against the model when possible
if (model) {
for (const flag of instr.flagsAffected) {
if (!model.flags.includes(flag)) {
tag(
'warning',
`flagsAffected lists "${flag}", which the design does not define`,
)
}
}
}
}
export function validateIsa(
isa: IsaDefinition,
model: MachineModel | null,
): IsaDiagnostic[] {
const diagnostics: IsaDiagnostic[] = []
const push = (d: IsaDiagnostic) => diagnostics.push(d)
// Mnemonic and opcode uniqueness across the ISA
const byMnemonic = new Map<string, Instruction>()
const byOpcode = new Map<number, Instruction>()
for (const instr of isa.instructions) {
const key = instr.mnemonic.toLowerCase()
const nameClash = byMnemonic.get(key)
if (nameClash) {
push({
severity: 'error',
message: `duplicate mnemonic "${instr.mnemonic}"`,
instructionId: instr.id,
})
} else {
byMnemonic.set(key, instr)
}
const opcodeClash = byOpcode.get(instr.opcode)
if (opcodeClash) {
push({
severity: 'error',
message: `${instr.mnemonic}: opcode ${instr.opcode} collides with "${opcodeClash.mnemonic}"`,
instructionId: instr.id,
})
} else {
byOpcode.set(instr.opcode, instr)
}
}
for (const instr of isa.instructions) {
validateInstruction(instr, isa, model, push)
}
return diagnostics
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import type { WmEdge, WmNode } from '../editor/nodeTypes'
import { compileGraph } from './compileGraph'
let counter = 0
function node(
type: string,
name: string,
params: Record<string, string | number> = {},
): WmNode {
return {
id: `n${++counter}`,
type,
position: { x: 0, y: 0 },
data: { name, doc: '', params },
}
}
function pcToMemEdge(pc: WmNode, mem: WmNode): WmEdge {
return {
id: `e${++counter}`,
source: pc.id,
sourceHandle: 'out',
target: mem.id,
targetHandle: 'addr',
data: { kind: 'data' },
}
}
function fullGraph() {
const pc = node('pc', 'PC1', { width: 12 })
const mem = node('memory', 'MAIN', { size: 4096, width: 8 })
const acc = node('register', 'ACC', { width: 8 })
const rf = node('registerFile', 'R', { count: 4, width: 8 })
const flags = node('flags', 'FLAGS1', { flags: 'Z, N, C' })
const alu = node('alu', 'ALU1', { width: 8 })
const note = node('comment', 'a note!', { text: 'hello' })
return {
nodes: [pc, mem, acc, rf, flags, alu, note],
edges: [pcToMemEdge(pc, mem)],
}
}
describe('compileGraph', () => {
it('compiles a full graph into a valid machine model', () => {
const { nodes, edges } = fullGraph()
const { model, diagnostics } = compileGraph(nodes, edges)
expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([])
expect(model).not.toBeNull()
if (!model) return
expect(model.banks).toEqual([
{ name: 'ACC', width: 8, count: 1, sourceNodeId: expect.any(String) },
{ name: 'R', width: 8, count: 4, sourceNodeId: expect.any(String) },
])
expect(model.memories).toEqual([
{
name: 'MAIN',
size: 4096,
width: 8,
sourceNodeId: expect.any(String),
},
])
expect(model.flags).toEqual(['Z', 'N', 'C'])
expect(model.pc).toEqual({ width: 12 })
expect(model.programMemory).toBe('MAIN')
})
it('is deterministic regardless of node order', () => {
const { nodes, edges } = fullGraph()
const forward = compileGraph(nodes, edges).model
const backward = compileGraph([...nodes].reverse(), edges).model
// sourceNodeIds differ per construction run, so compare shapes without them
const strip = (m: typeof forward) =>
JSON.parse(
JSON.stringify(m, (k, v: unknown) =>
k === 'sourceNodeId' ? undefined : v,
),
) as unknown
expect(strip(backward)).toEqual(strip(forward))
})
it('returns no model when validation errors exist', () => {
const { model, diagnostics } = compileGraph([], [])
expect(model).toBeNull()
expect(diagnostics.some((d) => d.severity === 'error')).toBe(true)
})
it('resolves the program memory via the PC wire when there are several memories', () => {
const { nodes, edges } = fullGraph()
nodes.push(node('memory', 'DATA', { size: 256, width: 8 }))
const { model } = compileGraph(nodes, edges)
expect(model?.programMemory).toBe('MAIN')
})
it('errors when the program memory is ambiguous', () => {
const { nodes } = fullGraph()
nodes.push(node('memory', 'DATA', { size: 256, width: 8 }))
const { model, diagnostics } = compileGraph(nodes, []) // no PC wire
expect(model).toBeNull()
expect(diagnostics.map((d) => d.message).join()).toContain(
'cannot determine the program memory',
)
})
it('rejects bad flag names', () => {
const { nodes, edges } = fullGraph()
const flags = nodes.find((n) => n.type === 'flags')
if (flags) flags.data.params = { flags: 'Z,9bad,Z' }
const { model, diagnostics } = compileGraph(nodes, edges)
expect(model).toBeNull()
const text = diagnostics.map((d) => d.message).join()
expect(text).toContain('not a valid identifier')
expect(text).toContain('duplicate flag name')
})
it('clamps out-of-range params into the schema range', () => {
const { nodes, edges } = fullGraph()
const acc = nodes.find((n) => n.data.name === 'ACC')
if (acc) acc.data.params = { width: 99 }
const { model } = compileGraph(nodes, edges)
expect(model?.banks.find((b) => b.name === 'ACC')?.width).toBe(32)
})
})
+155
View File
@@ -0,0 +1,155 @@
import type { WmEdge, WmNode } from '../editor/nodeTypes'
import {
machineModelSchema,
type MachineModel,
type MemoryDecl,
type RegisterBank,
} from './machineModel'
import { validateGraph, type Diagnostic } from './validateGraph'
export interface CompileResult {
/** Null when any error-severity diagnostic was produced. */
model: MachineModel | null
diagnostics: Diagnostic[]
}
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
function intParam(
node: WmNode,
key: string,
min: number,
max: number,
fallback: number,
): number {
const raw = Number(node.data.params[key])
if (!Number.isInteger(raw)) return fallback
return Math.min(max, Math.max(min, raw))
}
function parseFlagNames(node: WmNode, diagnostics: Diagnostic[]): string[] {
const names = String(node.data.params['flags'] ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
const result: string[] = []
for (const name of names) {
if (!NAME_RE.test(name)) {
diagnostics.push({
severity: 'error',
message: `"${node.data.name}": flag name "${name}" is not a valid identifier`,
nodeId: node.id,
})
continue
}
if (result.includes(name)) {
diagnostics.push({
severity: 'error',
message: `"${node.data.name}": duplicate flag name "${name}"`,
nodeId: node.id,
})
continue
}
result.push(name)
}
return result
}
/**
* Which memory holds the program? The one whose Address input is wired
* directly from the PC's Address output; with exactly one memory the choice
* is unambiguous anyway.
*/
function findProgramMemory(
memories: WmNode[],
pc: WmNode | undefined,
edges: WmEdge[],
diagnostics: Diagnostic[],
): string | null {
if (memories.length === 1 && memories[0]) return memories[0].data.name
const fedFromPc = memories.filter((mem) =>
edges.some(
(e) =>
e.source === pc?.id &&
e.sourceHandle === 'out' &&
e.target === mem.id &&
e.targetHandle === 'addr',
),
)
if (fedFromPc.length === 1 && fedFromPc[0]) return fedFromPc[0].data.name
diagnostics.push({
severity: 'error',
message:
memories.length === 0
? 'the design needs at least one Memory block to hold a program'
: 'cannot determine the program memory - connect the Program Counters Address output to exactly one Memorys Address input',
})
return null
}
export function compileGraph(nodes: WmNode[], edges: WmEdge[]): CompileResult {
const diagnostics = validateGraph(nodes, edges)
const semantic = nodes.filter((n) => n.type !== 'comment')
const ofType = (t: string) => semantic.filter((n) => n.type === t)
const banks: RegisterBank[] = [
...ofType('register').map((node) => ({
name: node.data.name,
width: intParam(node, 'width', 1, 32, 8),
count: 1,
sourceNodeId: node.id,
})),
...ofType('registerFile').map((node) => ({
name: node.data.name,
width: intParam(node, 'width', 1, 32, 8),
count: intParam(node, 'count', 1, 64, 4),
sourceNodeId: node.id,
})),
].sort((a, b) => a.name.localeCompare(b.name))
const memories: MemoryDecl[] = ofType('memory')
.map((node) => ({
name: node.data.name,
size: intParam(node, 'size', 1, 65536, 256),
width: intParam(node, 'width', 1, 32, 8),
sourceNodeId: node.id,
}))
.sort((a, b) => a.name.localeCompare(b.name))
const flagsNode = ofType('flags')[0]
const flags = flagsNode ? parseFlagNames(flagsNode, diagnostics) : []
const pcNode = ofType('pc')[0]
const programMemory = findProgramMemory(
ofType('memory'),
pcNode,
edges,
diagnostics,
)
if (diagnostics.some((d) => d.severity === 'error') || !programMemory) {
return { model: null, diagnostics }
}
const model: MachineModel = {
modelVersion: 1,
banks,
memories,
flags,
pc: { width: pcNode ? intParam(pcNode, 'width', 1, 32, 16) : 16 },
programMemory,
}
// The schema is the contract with the C++ engine - never emit outside it.
const checked = machineModelSchema.safeParse(model)
if (!checked.success) {
diagnostics.push({
severity: 'error',
message: `internal error: compiled model is invalid (${checked.error.issues[0]?.message ?? 'unknown'})`,
})
return { model: null, diagnostics }
}
return { model: checked.data, diagnostics }
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import vectorsJson from '../../../conformance/microop-vectors.json'
import { runVector, vectorFileSchema } from './conformance'
const file = vectorFileSchema.parse(vectorsJson)
describe('conformance vector file', () => {
it('has unique vector names', () => {
const names = file.vectors.map((v) => v.name)
expect(new Set(names).size).toBe(names.length)
})
})
describe('reference executor conformance', () => {
for (const vector of file.vectors) {
it(vector.name, () => {
const result = runVector(vector, file.defaultModel)
expect(result.problems, result.problems.join('; ')).toEqual([])
})
}
})
+147
View File
@@ -0,0 +1,147 @@
import { z } from 'zod'
import { machineModelSchema, type MachineModel } from './machineModel'
import { microOpSequenceSchema } from './microOps'
import { createState, executeSequence, type MachineState } from './interpret'
const valueMap = z.record(z.string(), z.number().int().min(0))
const setupSchema = z.object({
/** Full register arrays per bank (length may be shorter; rest stays 0). */
banks: z.record(z.string(), z.array(z.number().int().min(0))).optional(),
/** Sparse memory contents: { "MEM": { "0": 5, "12": 255 } } */
memories: z.record(z.string(), valueMap).optional(),
flags: z.record(z.string(), z.boolean()).optional(),
pc: z.number().int().min(0).optional(),
})
const expectSchema = setupSchema.extend({
halted: z.boolean().optional(),
})
export const vectorSchema = z
.object({
name: z.string().min(1),
/** Overrides the file's defaultModel when present. */
model: machineModelSchema.optional(),
setup: setupSchema.optional(),
/** Sequences executed in order (each models one instruction's behavior). */
run: z.array(
z.object({
operands: z.record(z.string(), z.number().int()).optional(),
microOps: microOpSequenceSchema,
}),
),
expect: expectSchema.optional(),
/** Substring that must appear in the execution error. */
expectError: z.string().optional(),
})
.refine((v) => (v.expect === undefined) !== (v.expectError === undefined), {
message: 'vector must have exactly one of expect / expectError',
})
export const vectorFileSchema = z.object({
description: z.string(),
defaultModel: machineModelSchema,
vectors: z.array(vectorSchema),
})
export type ConformanceVector = z.infer<typeof vectorSchema>
export type ConformanceFile = z.infer<typeof vectorFileSchema>
export function applySetup(
state: MachineState,
setup: z.infer<typeof setupSchema>,
): void {
for (const [bank, values] of Object.entries(setup.banks ?? {})) {
const target = state.banks[bank]
if (!target) throw new Error(`setup: unknown bank "${bank}"`)
values.forEach((v, i) => {
target[i] = v
})
}
for (const [mem, cells] of Object.entries(setup.memories ?? {})) {
const target = state.memories[mem]
if (!target) throw new Error(`setup: unknown memory "${mem}"`)
for (const [addr, value] of Object.entries(cells)) {
target[Number(addr)] = value
}
}
for (const [flag, value] of Object.entries(setup.flags ?? {})) {
if (!(flag in state.flags)) throw new Error(`setup: unknown flag "${flag}"`)
state.flags[flag] = value
}
if (setup.pc !== undefined) state.pc = setup.pc
}
/** Compare partial expectations; returns human-readable mismatches. */
export function compareExpect(
state: MachineState,
expect: z.infer<typeof expectSchema>,
): string[] {
const problems: string[] = []
for (const [bank, values] of Object.entries(expect.banks ?? {})) {
values.forEach((want, i) => {
const got = state.banks[bank]?.[i]
if (got !== want) problems.push(`${bank}[${i}] = ${got}, want ${want}`)
})
}
for (const [mem, cells] of Object.entries(expect.memories ?? {})) {
for (const [addr, want] of Object.entries(cells)) {
const got = state.memories[mem]?.[Number(addr)]
if (got !== want) problems.push(`${mem}[${addr}] = ${got}, want ${want}`)
}
}
for (const [flag, want] of Object.entries(expect.flags ?? {})) {
const got = state.flags[flag]
if (got !== want) problems.push(`flag ${flag} = ${got}, want ${want}`)
}
if (expect.pc !== undefined && state.pc !== expect.pc) {
problems.push(`pc = ${state.pc}, want ${expect.pc}`)
}
if (expect.halted !== undefined && state.halted !== expect.halted) {
problems.push(`halted = ${state.halted}, want ${expect.halted}`)
}
return problems
}
export type VectorResult = { name: string; problems: string[] }
/** Run one vector against the reference executor. */
export function runVector(
vector: ConformanceVector,
defaultModel: MachineModel,
): VectorResult {
const state = createState(vector.model ?? defaultModel)
if (vector.setup) applySetup(state, vector.setup)
let error: string | null = null
for (const step of vector.run) {
const outcome = executeSequence(state, step.microOps, step.operands ?? {})
if (!outcome.ok) {
error = outcome.error
break
}
}
if (vector.expectError !== undefined) {
if (error === null) {
return { name: vector.name, problems: ['expected an error, got none'] }
}
if (!error.includes(vector.expectError)) {
return {
name: vector.name,
problems: [`error "${error}" does not contain "${vector.expectError}"`],
}
}
return { name: vector.name, problems: [] }
}
if (error !== null) {
return { name: vector.name, problems: [`unexpected error: ${error}`] }
}
return {
name: vector.name,
problems: vector.expect ? compareExpect(state, vector.expect) : [],
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { AsmSegment } from '../asm/assembler'
import { fieldMaxValue } from '../isa/encoding'
import type { IsaDefinition } from '../isa/isaModel'
import { maskOf, type MachineModel } from './machineModel'
import { createState, executeSequence, type MachineState } from './interpret'
export interface ReferenceRun {
state: MachineState
/** Instructions completed before stopping. */
instructions: number
outcome: 'halted' | 'error' | 'max-steps'
error?: string
}
/**
* Load `segments` into the model's program memory and execute the
* instruction cycle until the machine halts, errors, or `maxSteps`
* instructions have run (guarding tests against runaway loops).
*/
export function runProgramReference(
model: MachineModel,
isa: IsaDefinition,
segments: AsmSegment[],
maxSteps = 100_000,
): ReferenceRun {
const state = createState(model)
const memory = state.memories[model.programMemory]
if (!memory) {
return {
state,
instructions: 0,
outcome: 'error',
error: `unknown program memory "${model.programMemory}"`,
}
}
for (const segment of segments) {
for (const [i, value] of segment.values.entries()) {
memory[segment.address + i] = value
}
}
const fail = (instructions: number, error: string): ReferenceRun => ({
state,
instructions,
outcome: 'error',
error,
})
for (let executed = 0; executed < maxSteps; executed++) {
if (state.halted)
return { state, instructions: executed, outcome: 'halted' }
// Fetch word 0 and decode the opcode.
const pcNow = state.pc
if (pcNow >= memory.length) {
return fail(executed, `fetch address ${pcNow} out of range`)
}
const word0 = memory[pcNow] ?? 0
const opcode =
(word0 >>> isa.opcodeField.offset) & maskOf(isa.opcodeField.width)
const def = isa.instructions.find((i) => i.opcode === opcode)
if (!def) {
return fail(executed, `unknown opcode ${opcode} at address ${pcNow}`)
}
// Fetch the remaining words and extract the operand fields.
if (pcNow + def.words > memory.length) {
return fail(
executed,
`instruction at address ${pcNow} runs past the end of memory`,
)
}
const operands: Record<string, number> = {}
for (const slot of def.operands) {
const word = memory[pcNow + slot.field.word] ?? 0
operands[slot.name] =
(word >>> slot.field.offset) & fieldMaxValue(slot.field)
}
// Advance the pc past the instruction BEFORE executing (§5).
state.pc = (pcNow + def.words) & maskOf(model.pc.width)
const outcome = executeSequence(state, def.microOps, operands)
if (!outcome.ok) {
return fail(executed, `${def.mnemonic}: ${outcome.error}`)
}
}
return { state, instructions: maxSteps, outcome: 'max-steps' }
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Focused executor tests. The bulk of the semantics is covered by the shared
* conformance vectors (conformance.test.ts); these tests cover behaviors
* that are awkward to express there (call-level outcomes, schema guards).
*/
import { describe, expect, it } from 'vitest'
import type { MachineModel } from './machineModel'
import { createState, executeSequence } from './interpret'
import { microOpSchema, type MicroOp } from './microOps'
const model: MachineModel = {
modelVersion: 1,
banks: [{ name: 'R', width: 8, count: 2 }],
memories: [{ name: 'MAIN', size: 16, width: 8 }],
flags: ['Z', 'N', 'C'],
pc: { width: 8 },
programMemory: 'MAIN',
}
const setR0 = (value: number): MicroOp => ({
op: 'move',
dst: { kind: 'reg', bank: 'R', index: { kind: 'literal', value: 0 } },
src: { kind: 'const', value },
})
describe('executeSequence', () => {
it('creates zeroed state', () => {
const state = createState(model)
expect(state.banks['R']).toEqual([0, 0])
expect(state.memories['MAIN']).toHaveLength(16)
expect(state.flags).toEqual({ Z: false, N: false, C: false })
expect(state.pc).toBe(0)
expect(state.halted).toBe(false)
})
it('returns ok:false with a message on execution errors', () => {
const state = createState(model)
const outcome = executeSequence(state, [
{
op: 'load',
memory: 'NOPE',
dst: { kind: 'temp', index: 0 },
addr: { kind: 'const', value: 0 },
},
])
expect(outcome).toEqual({ ok: false, error: 'unknown memory "NOPE"' })
})
it('is a no-op on a halted machine', () => {
const state = createState(model)
expect(executeSequence(state, [{ op: 'halt' }])).toEqual({ ok: true })
expect(executeSequence(state, [setR0(7)])).toEqual({ ok: true })
expect(state.banks['R']?.[0]).toBe(0)
})
it('only sets flags that exist in the model', () => {
const noFlags: MachineModel = { ...model, flags: [] }
const state = createState(noFlags)
const outcome = executeSequence(state, [
{
op: 'alu',
fn: 'add',
width: 8,
dst: { kind: 'temp', index: 0 },
a: { kind: 'const', value: 0 },
b: { kind: 'const', value: 0 },
setFlags: true,
},
])
expect(outcome.ok).toBe(true)
expect(state.flags).toEqual({})
})
it('rejects binary ALU functions without a second input', () => {
const state = createState(model)
const outcome = executeSequence(state, [
{
op: 'alu',
fn: 'add',
width: 8,
dst: { kind: 'temp', index: 0 },
a: { kind: 'const', value: 1 },
setFlags: false,
},
])
expect(outcome.ok).toBe(false)
if (!outcome.ok) expect(outcome.error).toContain('second input')
})
})
describe('microOpSchema', () => {
it('accepts every op shape used by the executor', () => {
const ops: unknown[] = [
setR0(1),
{ op: 'halt' },
{ op: 'jump', target: { kind: 'pc' } },
{
op: 'branch',
flag: 'Z',
ifSet: false,
target: { kind: 'const', value: 0 },
},
{ op: 'setFlag', name: 'C', src: { kind: 'flag', name: 'Z' } },
]
for (const op of ops) {
expect(microOpSchema.safeParse(op).success, JSON.stringify(op)).toBe(true)
}
})
it('rejects malformed ops', () => {
expect(microOpSchema.safeParse({ op: 'move' }).success).toBe(false)
expect(microOpSchema.safeParse({ op: 'alu', fn: 'mul' }).success).toBe(
false,
)
expect(
microOpSchema.safeParse({
op: 'move',
dst: { kind: 'flag', name: 'Z' }, // flags are not move destinations
src: { kind: 'const', value: 1 },
}).success,
).toBe(false)
})
})
+298
View File
@@ -0,0 +1,298 @@
import { maskOf, type MachineModel } from './machineModel'
import { TEMP_COUNT, type Dst, type MicroOp, type Src } from './microOps'
export interface MachineState {
model: MachineModel
/** Bank name -> register values (masked to bank width). */
banks: Record<string, number[]>
/** Memory name -> word values (masked to memory width). */
memories: Record<string, number[]>
/** Flag name -> value. */
flags: Record<string, boolean>
pc: number
/** Per-sequence scratch temporaries (cleared at sequence start). */
temps: number[]
halted: boolean
}
export type ExecOutcome = { ok: true } | { ok: false; error: string }
export function createState(model: MachineModel): MachineState {
return {
model,
banks: Object.fromEntries(
model.banks.map((b) => [b.name, new Array<number>(b.count).fill(0)]),
),
memories: Object.fromEntries(
model.memories.map((m) => [m.name, new Array<number>(m.size).fill(0)]),
),
flags: Object.fromEntries(model.flags.map((f) => [f, false])),
pc: 0,
temps: new Array<number>(TEMP_COUNT).fill(0),
halted: false,
}
}
class ExecError extends Error {}
const u32 = (v: number) => v >>> 0
function resolveIndex(
index:
{ kind: 'literal'; value: number } | { kind: 'operand'; field: string },
operands: Record<string, number>,
): number {
if (index.kind === 'literal') return index.value
const value = operands[index.field]
if (value === undefined) {
throw new ExecError(`unknown operand field "${index.field}"`)
}
return value
}
function bankOf(state: MachineState, name: string) {
const bank = state.banks[name]
if (!bank) throw new ExecError(`unknown register bank "${name}"`)
return bank
}
function bankWidth(state: MachineState, name: string): number {
const decl = state.model.banks.find((b) => b.name === name)
if (!decl) throw new ExecError(`unknown register bank "${name}"`)
return decl.width
}
function evalSrc(
state: MachineState,
src: Src,
operands: Record<string, number>,
): number {
switch (src.kind) {
case 'const':
return u32(src.value)
case 'reg': {
const bank = bankOf(state, src.bank)
const index = resolveIndex(src.index, operands)
const value = bank[index]
if (value === undefined) {
throw new ExecError(
`register index ${index} out of range for bank "${src.bank}"`,
)
}
return value
}
case 'pc':
return state.pc
case 'temp': {
const value = state.temps[src.index]
if (value === undefined) {
throw new ExecError(`temp index ${src.index} out of range`)
}
return value
}
case 'operand': {
const value = operands[src.field]
if (value === undefined) {
throw new ExecError(`unknown operand field "${src.field}"`)
}
return u32(value)
}
case 'flag': {
const value = state.flags[src.name]
if (value === undefined) {
throw new ExecError(`unknown flag "${src.name}"`)
}
return value ? 1 : 0
}
}
}
/** Writes mask the value to the destination's width. */
function writeDst(
state: MachineState,
dst: Dst,
value: number,
operands: Record<string, number>,
): void {
switch (dst.kind) {
case 'reg': {
const bank = bankOf(state, dst.bank)
const index = resolveIndex(dst.index, operands)
if (index < 0 || index >= bank.length) {
throw new ExecError(
`register index ${index} out of range for bank "${dst.bank}"`,
)
}
bank[index] = u32(value & maskOf(bankWidth(state, dst.bank)))
return
}
case 'pc':
state.pc = u32(value & maskOf(state.model.pc.width))
return
case 'temp':
state.temps[dst.index] = u32(value)
return
}
}
function setFlagIfPresent(state: MachineState, name: string, value: boolean) {
if (name in state.flags) state.flags[name] = value
}
interface AluOutcome {
result: number
carry: boolean | null // null = leave C unchanged
}
function aluCompute(
fn: string,
a: number,
b: number,
width: number,
): AluOutcome {
const mask = maskOf(width)
const av = u32(a & mask)
const bv = u32(b & mask)
switch (fn) {
case 'add': {
const raw = av + bv
return { result: u32(raw & mask), carry: raw > mask }
}
case 'sub':
// C follows the ARM/6502 convention: set when NO borrow (a >= b).
return { result: u32((av - bv) & mask), carry: av >= bv }
case 'and':
return { result: u32(av & bv & mask), carry: false }
case 'or':
return { result: u32((av | bv) & mask), carry: false }
case 'xor':
return { result: u32((av ^ bv) & mask), carry: false }
case 'not':
return { result: u32(~av & mask), carry: false }
case 'shl':
// v1: single-bit shifts; carry = the bit shifted out.
return {
result: u32((av << 1) & mask),
carry: ((av >>> (width - 1)) & 1) === 1,
}
case 'shr':
return { result: u32(av >>> 1), carry: (av & 1) === 1 }
default:
throw new ExecError(`unknown ALU function "${fn}"`)
}
}
const BINARY_FNS = new Set(['add', 'sub', 'and', 'or', 'xor'])
function execOne(
state: MachineState,
op: MicroOp,
operands: Record<string, number>,
): 'continue' | 'halt' {
switch (op.op) {
case 'move':
writeDst(state, op.dst, evalSrc(state, op.src, operands), operands)
return 'continue'
case 'alu': {
if (BINARY_FNS.has(op.fn) && op.b === undefined) {
throw new ExecError(`ALU function "${op.fn}" needs a second input`)
}
const a = evalSrc(state, op.a, operands)
const b = op.b ? evalSrc(state, op.b, operands) : 0
const { result, carry } = aluCompute(op.fn, a, b, op.width)
writeDst(state, op.dst, result, operands)
if (op.setFlags) {
setFlagIfPresent(state, 'Z', result === 0)
setFlagIfPresent(state, 'N', ((result >>> (op.width - 1)) & 1) === 1)
if (carry !== null) setFlagIfPresent(state, 'C', carry)
}
return 'continue'
}
case 'load': {
const memory = state.memories[op.memory]
if (!memory) throw new ExecError(`unknown memory "${op.memory}"`)
const addr = evalSrc(state, op.addr, operands)
const value = memory[addr]
if (value === undefined) {
throw new ExecError(
`address ${addr} out of range for memory "${op.memory}"`,
)
}
writeDst(state, op.dst, value, operands)
return 'continue'
}
case 'store': {
const memory = state.memories[op.memory]
if (!memory) throw new ExecError(`unknown memory "${op.memory}"`)
const decl = state.model.memories.find((m) => m.name === op.memory)
const addr = evalSrc(state, op.addr, operands)
if (addr < 0 || addr >= memory.length) {
throw new ExecError(
`address ${addr} out of range for memory "${op.memory}"`,
)
}
memory[addr] = u32(
evalSrc(state, op.src, operands) & maskOf(decl?.width ?? 32),
)
return 'continue'
}
case 'setFlag': {
if (!(op.name in state.flags)) {
throw new ExecError(`unknown flag "${op.name}"`)
}
state.flags[op.name] = evalSrc(state, op.src, operands) !== 0
return 'continue'
}
case 'jump':
state.pc = u32(
evalSrc(state, op.target, operands) & maskOf(state.model.pc.width),
)
return 'continue'
case 'branch': {
const flag = state.flags[op.flag]
if (flag === undefined) throw new ExecError(`unknown flag "${op.flag}"`)
if (flag === op.ifSet) {
state.pc = u32(
evalSrc(state, op.target, operands) & maskOf(state.model.pc.width),
)
}
return 'continue'
}
case 'halt':
state.halted = true
return 'halt'
}
}
/**
* Execute one micro-op sequence (one instruction's behavior).
* - Temps are cleared at sequence start.
* - A halted machine ignores the call.
* - `halt` stops the remaining micro-ops of the sequence.
* - On error, execution stops; already-applied writes remain (error vectors
* only assert the error, not state).
*/
export function executeSequence(
state: MachineState,
microOps: MicroOp[],
operands: Record<string, number> = {},
): ExecOutcome {
if (state.halted) return { ok: true }
state.temps.fill(0)
try {
for (const op of microOps) {
if (execOne(state, op, operands) === 'halt') break
}
return { ok: true }
} catch (error) {
if (error instanceof ExecError) return { ok: false, error: error.message }
throw error
}
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod'
/** Component names double as assembly-level identifiers. */
export const identifierSchema = z
.string()
.regex(
/^[A-Za-z_][A-Za-z0-9_]*$/,
'must start with a letter/underscore and contain only letters, digits, underscores',
)
/** v1 executes with 32-bit arithmetic; widths beyond that are rejected. */
export const widthSchema = z.number().int().min(1).max(32)
export const registerBankSchema = z.object({
name: identifierSchema,
width: widthSchema,
/** Number of registers in the bank; a lone register is a bank of 1. */
count: z.number().int().min(1).max(64),
/** Graph node this was compiled from (for UI tracebacks; not semantic). */
sourceNodeId: z.string().optional(),
})
export const memorySchema = z.object({
name: identifierSchema,
/** Number of addressable words. */
size: z.number().int().min(1).max(65536),
/** Word width in bits. */
width: widthSchema,
sourceNodeId: z.string().optional(),
})
export const machineModelSchema = z.object({
modelVersion: z.literal(1),
banks: z.array(registerBankSchema),
memories: z.array(memorySchema),
/** Flag names, e.g. ["Z", "N", "C"]. May be empty. */
flags: z.array(identifierSchema),
pc: z.object({ width: widthSchema }),
/** Name of the memory instructions are fetched from. */
programMemory: identifierSchema,
})
export type RegisterBank = z.infer<typeof registerBankSchema>
export type MemoryDecl = z.infer<typeof memorySchema>
export type MachineModel = z.infer<typeof machineModelSchema>
/** Bit mask for a width (width ≤ 32), as an unsigned 32-bit value. */
export function maskOf(width: number): number {
return width >= 32 ? 0xffffffff : (1 << width) - 1
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from 'zod'
import { identifierSchema, widthSchema } from './machineModel'
/** How many per-sequence scratch temporaries exist (T0..T3, 32-bit). */
export const TEMP_COUNT = 4
const uint32Schema = z.number().int().min(0).max(0xffffffff)
const tempIndexSchema = z
.number()
.int()
.min(0)
.max(TEMP_COUNT - 1)
/** Register index within a bank: fixed, or taken from a decoded operand. */
export const srcIndexSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('literal'), value: z.number().int().min(0) }),
z.object({ kind: z.literal('operand'), field: identifierSchema }),
])
/** Value sources. */
export const srcSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('const'), value: uint32Schema }),
z.object({
kind: z.literal('reg'),
bank: identifierSchema,
index: srcIndexSchema,
}),
z.object({ kind: z.literal('pc') }),
z.object({ kind: z.literal('temp'), index: tempIndexSchema }),
/** A decoded operand field of the current instruction (e.g. an immediate). */
z.object({ kind: z.literal('operand'), field: identifierSchema }),
/** A flag read as 0 or 1. */
z.object({ kind: z.literal('flag'), name: identifierSchema }),
])
/** Write destinations. Flags are written via the dedicated setFlag op. */
export const dstSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('reg'),
bank: identifierSchema,
index: srcIndexSchema,
}),
z.object({ kind: z.literal('pc') }),
z.object({ kind: z.literal('temp'), index: tempIndexSchema }),
])
/**
* ALU functions. v1 keeps shifts single-bit (`b` unused for not/shl/shr);
* carry-in variants (adc/sbc) are reserved for a future model version.
*/
export const aluFnSchema = z.enum([
'add',
'sub',
'and',
'or',
'xor',
'not',
'shl',
'shr',
])
export const microOpSchema = z.discriminatedUnion('op', [
z.object({ op: z.literal('move'), dst: dstSchema, src: srcSchema }),
z.object({
op: z.literal('alu'),
fn: aluFnSchema,
/** Operation width in bits - inputs are truncated to it, result masked. */
width: widthSchema,
dst: dstSchema,
a: srcSchema,
b: srcSchema.optional(),
/** Update Z/N/C (those that exist in the model) from the result. */
setFlags: z.boolean().default(false),
}),
z.object({
op: z.literal('load'),
memory: identifierSchema,
dst: dstSchema,
addr: srcSchema,
}),
z.object({
op: z.literal('store'),
memory: identifierSchema,
addr: srcSchema,
src: srcSchema,
}),
z.object({
op: z.literal('setFlag'),
name: identifierSchema,
/** Flag becomes (value != 0). */
src: srcSchema,
}),
z.object({ op: z.literal('jump'), target: srcSchema }),
z.object({
op: z.literal('branch'),
flag: identifierSchema,
/** Branch taken when the flag equals this value. */
ifSet: z.boolean(),
target: srcSchema,
}),
z.object({ op: z.literal('halt') }),
])
export const microOpSequenceSchema = z.array(microOpSchema)
export type SrcIndex = z.infer<typeof srcIndexSchema>
export type Src = z.infer<typeof srcSchema>
export type Dst = z.infer<typeof dstSchema>
export type AluFn = z.infer<typeof aluFnSchema>
export type MicroOp = z.infer<typeof microOpSchema>
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import type { WmEdge, WmNode } from '../editor/nodeTypes'
import { portWidth, validateGraph } from './validateGraph'
let counter = 0
function node(
type: string,
name: string,
params: Record<string, string | number> = {},
): WmNode {
return {
id: `n${++counter}`,
type,
position: { x: 0, y: 0 },
data: { name, doc: '', params },
}
}
function edge(
source: WmNode,
sourceHandle: string,
target: WmNode,
targetHandle: string,
kind: 'data' | 'control' = 'data',
): WmEdge {
return {
id: `e${++counter}`,
source: source.id,
sourceHandle,
target: target.id,
targetHandle,
data: { kind },
}
}
/** Minimal graph that passes all error-level checks. */
function soundGraph() {
const pc = node('pc', 'PC1', { width: 16 })
const mem = node('memory', 'MAIN', { size: 256, width: 8 })
const nodes = [pc, mem]
const edges = [edge(pc, 'out', mem, 'addr')]
return { nodes, edges }
}
const errorsOf = (nodes: WmNode[], edges: WmEdge[]) =>
validateGraph(nodes, edges).filter((d) => d.severity === 'error')
describe('validateGraph', () => {
it('produces no errors for a sound minimal graph', () => {
const { nodes, edges } = soundGraph()
expect(errorsOf(nodes, edges)).toEqual([])
})
it('flags invalid and duplicate names as errors', () => {
const { nodes, edges } = soundGraph()
nodes.push(node('register', '2fast'), node('register', 'pc1'))
const messages = errorsOf(nodes, edges).map((d) => d.message)
expect(messages.join()).toContain('not a valid identifier')
expect(messages.join()).toContain('duplicate block name')
})
it('requires a PC and at least one memory', () => {
expect(
errorsOf([], [])
.map((d) => d.message)
.join(),
).toMatch(/Program Counter[\s\S]*Memory|Memory[\s\S]*Program Counter/)
})
it('rejects multiple PCs and multiple flags blocks', () => {
const { nodes, edges } = soundGraph()
nodes.push(
node('pc', 'PC2', { width: 16 }),
node('flags', 'FLAGS1', { flags: 'Z' }),
node('flags', 'FLAGS2', { flags: 'Z' }),
)
const messages = errorsOf(nodes, edges).map((d) => d.message)
expect(messages.join()).toContain('only one Program Counter')
expect(messages.join()).toContain('only one Flags block')
})
it('warns about unconnected inputs', () => {
const { nodes, edges } = soundGraph()
nodes.push(node('register', 'REG1', { width: 8 }))
const warnings = validateGraph(nodes, edges).filter(
(d) => d.severity === 'warning',
)
expect(warnings.map((d) => d.message).join()).toContain(
'"REG1": input "Data in" is not connected',
)
})
it('warns about data bus width mismatches', () => {
const { nodes, edges } = soundGraph()
const reg = node('register', 'REG1', { width: 8 })
const alu = node('alu', 'ALU1', { width: 16 })
nodes.push(reg, alu)
edges.push(edge(reg, 'out', alu, 'a'))
const warnings = validateGraph(nodes, edges).filter(
(d) => d.severity === 'warning',
)
expect(warnings.map((d) => d.message).join()).toContain(
'bus width mismatch',
)
})
it('ignores comment nodes entirely', () => {
const { nodes, edges } = soundGraph()
nodes.push(node('comment', 'not an identifier!!', { text: 'hi' }))
expect(errorsOf(nodes, edges)).toEqual([])
})
})
describe('portWidth', () => {
it('derives widths from params', () => {
const reg = node('register', 'R1', { width: 12 })
expect(portWidth(reg, 'in')).toBe(12)
expect(portWidth(reg, 'load')).toBeNull() // control port
const mem = node('memory', 'M1', { size: 256, width: 8 })
expect(portWidth(mem, 'addr')).toBe(8)
expect(portWidth(mem, 'dout')).toBe(8)
const flags = node('flags', 'F1', { flags: 'Z,N,C' })
expect(portWidth(flags, 'out')).toBe(3)
})
})
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { initPersistence } from './model/persistence'
initPersistence()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+42
View File
@@ -0,0 +1,42 @@
/** Browser file download/upload helpers for `.webmetal.json` documents. */
import { loadProject, parseProjectJson, projectToJson } from './serialize'
import type { ProjectDoc } from './projectSchema'
/** Import arbitrary text as a project, with a user-visible error on failure. */
export function importProjectText(text: string): boolean {
const parsed = parseProjectJson(text)
if (!parsed.ok) {
window.alert(`Could not import project: ${parsed.error}`)
return false
}
loadProject(parsed.doc)
return true
}
/** "My 8-bit CPU!" -> "my-8-bit-cpu.webmetal.json" */
export function projectFileName(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `${slug || 'project'}.webmetal.json`
}
export function downloadProjectFile(doc: ProjectDoc): void {
const blob = new Blob([projectToJson(doc)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = projectFileName(doc.metadata.name)
anchor.click()
URL.revokeObjectURL(url)
}
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.readAsText(file)
})
}
+154
View File
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { duplicateName, useGraphStore } from '../editor/graphStore'
import { useIsaStore } from '../isa/isaStore'
import { HISTORY_LIMIT, useHistoryStore } from './history'
const graph = () => useGraphStore.getState()
const isa = () => useIsaStore.getState()
const history = () => useHistoryStore.getState()
beforeEach(() => {
graph().loadGraph([], [], { x: 0, y: 0, zoom: 1 })
isa().resetIsa()
history().clear()
})
describe('undo/redo history', () => {
it('undoes and redoes a node addition', () => {
graph().addNode('register', { x: 10, y: 10 })
expect(graph().nodes).toHaveLength(1)
expect(useHistoryStore.getState().past).toHaveLength(1)
history().undo()
expect(graph().nodes).toHaveLength(0)
history().redo()
expect(graph().nodes).toHaveLength(1)
expect(graph().nodes[0]?.data.name).toBe('REG1')
})
it('covers ISA edits in the same timeline', () => {
isa().addInstruction()
graph().addNode('memory', { x: 0, y: 0 })
expect(isa().isa.instructions).toHaveLength(1)
history().undo() // removes the node
expect(graph().nodes).toHaveLength(0)
expect(isa().isa.instructions).toHaveLength(1)
history().undo() // removes the instruction
expect(isa().isa.instructions).toHaveLength(0)
history().redo()
expect(isa().isa.instructions).toHaveLength(1)
})
it('coalesces same-key checkpoints (typing bursts)', () => {
graph().addNode('register', { x: 0, y: 0 })
const id = graph().nodes[0]?.id ?? ''
// Simulates keystrokes: one updateNodeData per character.
graph().updateNodeData(id, { name: 'A' })
graph().updateNodeData(id, { name: 'AC' })
graph().updateNodeData(id, { name: 'ACC' })
expect(graph().nodes[0]?.data.name).toBe('ACC')
// addNode + one coalesced rename entry.
expect(useHistoryStore.getState().past).toHaveLength(2)
history().undo()
expect(graph().nodes[0]?.data.name).toBe('REG1')
})
it('a new edit clears the redo stack', () => {
graph().addNode('register', { x: 0, y: 0 })
history().undo()
expect(useHistoryStore.getState().future).toHaveLength(1)
graph().addNode('memory', { x: 0, y: 0 })
expect(useHistoryStore.getState().future).toHaveLength(0)
})
it('caps the history depth', () => {
for (let i = 0; i < HISTORY_LIMIT + 20; i++) {
history().checkpoint()
}
expect(useHistoryStore.getState().past).toHaveLength(HISTORY_LIMIT)
})
it('undo with empty history is a no-op', () => {
graph().addNode('register', { x: 0, y: 0 })
history().clear()
history().undo()
expect(graph().nodes).toHaveLength(1)
})
it('keeps the current viewport when undoing', () => {
graph().addNode('register', { x: 0, y: 0 })
graph().storeViewport({ x: 123, y: 45, zoom: 1.5 })
history().undo()
expect(graph().viewport).toEqual({ x: 123, y: 45, zoom: 1.5 })
})
})
describe('duplicateName', () => {
it('bumps numeric suffixes past used names', () => {
expect(duplicateName('REG2', new Set(['reg1', 'reg2', 'reg3']))).toBe(
'REG4',
)
})
it('falls back to _copy suffixes', () => {
const used = new Set(['acc', 'acc_copy'])
expect(duplicateName('ACC', used)).toBe('ACC_copy2')
})
})
describe('duplicateSelection', () => {
it('copies selected nodes and the wires between them', () => {
graph().addNode('pc', { x: 0, y: 0 })
graph().addNode('memory', { x: 100, y: 0 })
const [pc, mem] = graph().nodes
if (!pc || !mem) throw new Error('setup failed')
graph().onConnect({
source: pc.id,
sourceHandle: 'out',
target: mem.id,
targetHandle: 'addr',
})
// Select both nodes.
useGraphStore.setState({
nodes: graph().nodes.map((n) => ({ ...n, selected: true })),
})
graph().duplicateSelection()
expect(graph().nodes).toHaveLength(4)
expect(graph().edges).toHaveLength(2)
const names = graph()
.nodes.map((n) => n.data.name)
.sort()
expect(new Set(names).size).toBe(4) // all names unique
// Copies are selected; originals are not.
const selected = graph().nodes.filter((n) => n.selected)
expect(selected).toHaveLength(2)
expect(selected.every((n) => n.position.x % 100 === 32)).toBe(true)
// The copied wire connects the two copies, not the originals.
const copyIds = new Set(selected.map((n) => n.id))
const copiedEdge = graph().edges.find((e) => copyIds.has(e.source))
expect(copiedEdge && copyIds.has(copiedEdge.target)).toBe(true)
history().undo()
expect(graph().nodes).toHaveLength(2)
expect(graph().edges).toHaveLength(1)
})
it('does nothing without a selection', () => {
graph().addNode('register', { x: 0, y: 0 })
useGraphStore.setState({
nodes: graph().nodes.map((n) => ({ ...n, selected: false })),
})
const pastBefore = useHistoryStore.getState().past.length
graph().duplicateSelection()
expect(graph().nodes).toHaveLength(1)
expect(useHistoryStore.getState().past).toHaveLength(pastBefore)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { create } from 'zustand'
import { useGraphStore } from '../editor/graphStore'
import type { WmEdge, WmNode } from '../editor/nodeTypes'
import type { IsaDefinition } from '../isa/isaModel'
import { useIsaStore } from '../isa/isaStore'
interface Snapshot {
nodes: WmNode[]
edges: WmEdge[]
isa: IsaDefinition
}
export const HISTORY_LIMIT = 100
const COALESCE_MS = 1000
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
function capture(): Snapshot {
const { nodes, edges } = useGraphStore.getState()
return {
nodes: clone(nodes),
edges: clone(edges),
isa: clone(useIsaStore.getState().isa),
}
}
function restore(snapshot: Snapshot): void {
const graph = useGraphStore.getState()
// Keep the current camera - undo should never yank the viewport around.
graph.loadGraph(clone(snapshot.nodes), clone(snapshot.edges), graph.viewport)
useIsaStore.getState().loadIsa(clone(snapshot.isa))
}
// Coalescing bookkeeping (not reactive state).
let lastKey: string | null = null
let lastTime = 0
interface HistoryState {
past: Snapshot[]
future: Snapshot[]
checkpoint: (key?: string) => void
undo: () => void
redo: () => void
/** Drop all history (project load/new/import). */
clear: () => void
}
export const useHistoryStore = create<HistoryState>()((set, get) => ({
past: [],
future: [],
checkpoint: (key) => {
const now = Date.now()
if (key !== undefined && key === lastKey && now - lastTime < COALESCE_MS) {
lastTime = now
return
}
lastKey = key ?? null
lastTime = now
set({
past: [...get().past.slice(-(HISTORY_LIMIT - 1)), capture()],
future: [],
})
},
undo: () => {
const { past, future } = get()
const previous = past[past.length - 1]
if (!previous) return
const current = capture()
restore(previous)
set({ past: past.slice(0, -1), future: [...future, current] })
lastKey = null
},
redo: () => {
const { past, future } = get()
const next = future[future.length - 1]
if (!next) return
const current = capture()
restore(next)
set({ past: [...past, current], future: future.slice(0, -1) })
lastKey = null
},
clear: () => {
lastKey = null
set({ past: [], future: [] })
},
}))
/** Convenience for non-React callers (store actions). */
export function checkpoint(key?: string): void {
useHistoryStore.getState().checkpoint(key)
}
export function clearHistory(): void {
useHistoryStore.getState().clear()
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
CURRENT_FORMAT_VERSION,
migrateToCurrent,
runMigrations,
type Migration,
} from './migrations'
describe('runMigrations', () => {
const chain: Record<number, Migration> = {
1: (doc) => ({ ...doc, formatVersion: 2, addedInV2: true }),
2: (doc) => ({ ...doc, formatVersion: 3, addedInV3: true }),
}
it('passes a document already at the target version through unchanged', () => {
const doc = { formatVersion: 3, payload: 'x' }
const result = runMigrations(doc, chain, 3)
expect(result).toEqual({ ok: true, doc })
})
it('applies the full chain in order', () => {
const result = runMigrations({ formatVersion: 1 }, chain, 3)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.doc).toEqual({
formatVersion: 3,
addedInV2: true,
addedInV3: true,
})
}
})
it('rejects documents from a newer version', () => {
const result = runMigrations({ formatVersion: 4 }, chain, 3)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('newer')
})
it('rejects gaps in the migration chain', () => {
const result = runMigrations({ formatVersion: 1 }, { 2: chain[2]! }, 3)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('no migration path')
})
it('rejects missing or malformed formatVersion', () => {
expect(runMigrations({}, chain, 3).ok).toBe(false)
expect(runMigrations({ formatVersion: '1' }, chain, 3).ok).toBe(false)
expect(runMigrations({ formatVersion: 0 }, chain, 3).ok).toBe(false)
})
it('detects a migration that fails to bump the version', () => {
const bad: Record<number, Migration> = { 1: (doc) => doc }
const result = runMigrations({ formatVersion: 1 }, bad, 2)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('misbehaved')
})
})
describe('migrateToCurrent', () => {
it('accepts a current-version document', () => {
const result = migrateToCurrent({ formatVersion: CURRENT_FORMAT_VERSION })
expect(result.ok).toBe(true)
})
it('migrates a v1 document through the whole chain to v3', () => {
const result = migrateToCurrent({
formatVersion: 1,
metadata: { name: 'Old Project' },
graph: { nodes: [], edges: [] },
isa: null,
programs: [],
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.doc['formatVersion']).toBe(CURRENT_FORMAT_VERSION)
const isa = result.doc['isa'] as { instructions: unknown[] }
expect(isa).not.toBeNull()
expect(isa.instructions).toEqual([])
expect(result.doc['programs']).toEqual([])
// Untouched fields survive
expect(result.doc['metadata']).toEqual({ name: 'Old Project' })
})
it('migrates a v2 document to v3 with a typed empty program list', () => {
const result = migrateToCurrent({ formatVersion: 2, programs: [] })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.doc['formatVersion']).toBe(3)
expect(result.doc['programs']).toEqual([])
})
})
+60
View File
@@ -0,0 +1,60 @@
import { defaultIsa } from '../isa/isaModel'
export const CURRENT_FORMAT_VERSION = 3
export type RawDoc = Record<string, unknown>
export type Migration = (doc: RawDoc) => RawDoc
/** Registered migrations, keyed by the version they migrate FROM. */
export const MIGRATIONS: Readonly<Record<number, Migration>> = {
// v1 -> v2: the `isa: null` placeholder becomes a real (empty) ISA.
1: (doc) => ({ ...doc, formatVersion: 2, isa: defaultIsa() }),
// v2 -> v3: `programs` becomes a typed list. v2 always wrote []; any
// unschema'd content is dropped rather than guessed at.
2: (doc) => ({ ...doc, formatVersion: 3, programs: [] }),
}
export type MigrateResult =
{ ok: true; doc: RawDoc } | { ok: false; error: string }
/**
* Pure worker so the chain logic is testable with injected migration maps.
*/
export function runMigrations(
doc: RawDoc,
migrations: Readonly<Record<number, Migration>>,
targetVersion: number,
): MigrateResult {
const version = doc['formatVersion']
if (
typeof version !== 'number' ||
!Number.isInteger(version) ||
version < 1
) {
return { ok: false, error: 'missing or invalid formatVersion' }
}
if (version > targetVersion) {
return {
ok: false,
error:
`this project uses format version ${version}, which is newer than ` +
`this build of WebMetal supports (${targetVersion}) - please update WebMetal`,
}
}
let current = doc
for (let v = version; v < targetVersion; v++) {
const step = migrations[v]
if (!step) {
return { ok: false, error: `no migration path from format version ${v}` }
}
current = step(current)
if (current['formatVersion'] !== v + 1) {
return { ok: false, error: `migration from version ${v} misbehaved` }
}
}
return { ok: true, doc: current }
}
export function migrateToCurrent(doc: RawDoc): MigrateResult {
return runMigrations(doc, MIGRATIONS, CURRENT_FORMAT_VERSION)
}
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useProgramStore } from '../asm/programStore'
import { DEFAULT_PROJECT_META, useAppStore } from '../app/store'
import { useGraphStore } from '../editor/graphStore'
import { defaultIsa } from '../isa/isaModel'
import { useIsaStore } from '../isa/isaStore'
import {
PROJECT_STORAGE_KEY,
restoreProjectFromStorage,
saveProjectToStorage,
} from './persistence'
/** Minimal in-memory Storage implementation for node-environment tests. */
function fakeStorage(): Storage {
const map = new Map<string, string>()
return {
get length() {
return map.size
},
clear: () => map.clear(),
getItem: (k) => map.get(k) ?? null,
key: (i) => [...map.keys()][i] ?? null,
removeItem: (k) => void map.delete(k),
setItem: (k, v) => void map.set(k, v),
}
}
beforeEach(() => {
useGraphStore.setState({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
revision: 0,
})
useIsaStore.setState({ isa: defaultIsa(), selectedId: null })
useProgramStore.getState().resetPrograms()
useAppStore.setState({ projectMeta: { ...DEFAULT_PROJECT_META } })
})
describe('save + restore via storage', () => {
it('round-trips the current project', () => {
useGraphStore.getState().addNode('memory', { x: 48, y: 16 })
useAppStore.getState().setProjectMeta({ name: 'Persisted CPU' })
const storage = fakeStorage()
expect(saveProjectToStorage(storage)).toBe(true)
// Wipe and restore.
useGraphStore.setState({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
revision: 0,
})
useAppStore.setState({ projectMeta: { ...DEFAULT_PROJECT_META } })
expect(restoreProjectFromStorage(storage)).toBe(true)
expect(useGraphStore.getState().nodes).toHaveLength(1)
expect(useAppStore.getState().projectMeta.name).toBe('Persisted CPU')
})
it('returns false when nothing is stored', () => {
expect(restoreProjectFromStorage(fakeStorage())).toBe(false)
})
it('drops a corrupt autosave instead of failing startup', () => {
const storage = fakeStorage()
storage.setItem(PROJECT_STORAGE_KEY, '{broken json')
expect(restoreProjectFromStorage(storage)).toBe(false)
expect(storage.getItem(PROJECT_STORAGE_KEY)).toBeNull()
})
it('handles a null storage gracefully', () => {
expect(saveProjectToStorage(null)).toBe(false)
expect(restoreProjectFromStorage(null)).toBe(false)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { useAppStore } from '../app/store'
import { useGraphStore } from '../editor/graphStore'
import { useProgramStore } from '../asm/programStore'
import { useIsaStore } from '../isa/isaStore'
import {
loadProject,
parseProjectJson,
projectToJson,
snapshotProject,
} from './serialize'
export const PROJECT_STORAGE_KEY = 'webmetal.project'
const DEBOUNCE_MS = 500
function defaultStorage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
export function saveProjectToStorage(
storage: Storage | null = defaultStorage(),
): boolean {
if (!storage) return false
try {
storage.setItem(PROJECT_STORAGE_KEY, projectToJson(snapshotProject()))
return true
} catch {
// Quota exceeded or storage blocked - autosave is best-effort.
return false
}
}
/** Restore the autosaved project, if any. Returns true when one was loaded. */
export function restoreProjectFromStorage(
storage: Storage | null = defaultStorage(),
): boolean {
if (!storage) return false
let text: string | null = null
try {
text = storage.getItem(PROJECT_STORAGE_KEY)
} catch {
return false
}
if (!text) return false
const parsed = parseProjectJson(text)
if (!parsed.ok) {
// A corrupt autosave must not brick startup; drop it.
try {
storage.removeItem(PROJECT_STORAGE_KEY)
} catch {
/* ignore */
}
return false
}
loadProject(parsed.doc)
return true
}
/**
* Wire up autosave: restore once, then subscribe to both stores with a
* debounced save. Call exactly once at startup (before first render, so the
* canvas mounts with the restored viewport).
*/
export function initPersistence(): void {
restoreProjectFromStorage()
let timer: ReturnType<typeof setTimeout> | undefined
const scheduleSave = () => {
clearTimeout(timer)
timer = setTimeout(() => saveProjectToStorage(), DEBOUNCE_MS)
}
useGraphStore.subscribe(scheduleSave)
useIsaStore.subscribe((state, prev) => {
if (state.isa !== prev.isa) scheduleSave()
})
useProgramStore.subscribe((state, prev) => {
if (state.programs !== prev.programs) scheduleSave()
})
useAppStore.subscribe((state, prev) => {
if (state.projectMeta !== prev.projectMeta) scheduleSave()
})
window.addEventListener('beforeunload', () => {
clearTimeout(timer)
saveProjectToStorage()
})
}

Some files were not shown because too many files have changed in this diff Show More