mirror of
https://github.com/ApfelTeeSaft/WebMetal.git
synced 2026-08-26 19:43:24 +00:00
Release
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 machine’s 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 instruction’s 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 instruction’s 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 MEM’s 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 file’s 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 MAIN’s 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
package-lock.json
|
||||||
|
# Emscripten build output (scripts/build-wasm.sh)
|
||||||
|
public/wasm/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+2170
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppShell } from './app/AppShell'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppShell />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
& 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 & engine status
|
||||||
|
</Button>
|
||||||
|
<Button variant="solid" onClick={() => setHelpOpen(false)} autoFocus>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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 & output panel"
|
||||||
|
aria-label="Show assembly and output panel"
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</Button>
|
||||||
|
<span className="panel-rail-label-h">Assembly & 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 } })),
|
||||||
|
}))
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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_]*$/
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 Counter’s Address output to exactly one Memory’s 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 }
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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) : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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.
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
Reference in New Issue
Block a user