From cafc87e8ad7be191ba03ffd940d9c5154c9f86e7 Mon Sep 17 00:00:00 2001 From: ApfelTeeSaft <91074565+ApfelTeeSaft@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:05:04 +0200 Subject: [PATCH] Release --- .editorconfig | 15 + .github/workflows/ci.yml | 141 + .gitignore | 21 + LICENSE | 21 + README.md | 105 + conformance/microop-vectors.json | 556 + examples/edu-core/edu-core.webmetal.json | 525 + examples/retro-8/retro-8.webmetal.json | 860 + examples/toy-cpu/toy-cpu.webmetal.json | 661 + frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/.prettierignore | 5 + frontend/.prettierrc.json | 4 + frontend/index.html | 33 + frontend/package-lock.json | 2170 ++ frontend/package.json | 44 + frontend/public/favicon.svg | 11 + frontend/src/App.tsx | 7 + frontend/src/app/AboutDialog.tsx | 59 + frontend/src/app/AppShell.tsx | 65 + frontend/src/app/HelpDialog.tsx | 129 + frontend/src/app/Toolbar.tsx | 205 + frontend/src/app/meta.test.ts | 13 + frontend/src/app/meta.ts | 11 + frontend/src/app/panels.tsx | 221 + frontend/src/app/shell.css | 330 + frontend/src/app/shortcuts.ts | 100 + frontend/src/app/store.test.ts | 51 + frontend/src/app/store.ts | 120 + frontend/src/app/theme.test.ts | 36 + frontend/src/app/theme.ts | 58 + frontend/src/app/usePanelResize.ts | 59 + frontend/src/asm/AsmEditor.tsx | 188 + frontend/src/asm/AsmPanel.tsx | 227 + frontend/src/asm/asm.css | 177 + frontend/src/asm/asmLanguage.test.ts | 64 + frontend/src/asm/asmLanguage.ts | 110 + frontend/src/asm/assembler.test.ts | 252 + frontend/src/asm/assembler.ts | 450 + frontend/src/asm/debugExtensions.ts | 91 + frontend/src/asm/editorNav.ts | 31 + frontend/src/asm/programStore.test.ts | 75 + frontend/src/asm/programStore.ts | 104 + frontend/src/asm/syntax.test.ts | 42 + frontend/src/asm/syntax.ts | 40 + frontend/src/editor/BlockNode.tsx | 98 + frontend/src/editor/GraphCanvas.tsx | 147 + frontend/src/editor/Inspector.tsx | 205 + frontend/src/editor/Toolbox.tsx | 72 + frontend/src/editor/editor.css | 386 + frontend/src/editor/graphRules.test.ts | 87 + frontend/src/editor/graphRules.ts | 60 + frontend/src/editor/graphStore.test.ts | 105 + frontend/src/editor/graphStore.ts | 213 + frontend/src/editor/nodeTypes.test.ts | 66 + frontend/src/editor/nodeTypes.ts | 285 + frontend/src/emulator/DebuggerView.tsx | 375 + frontend/src/emulator/debugStore.ts | 377 + frontend/src/emulator/debugUtils.test.ts | 70 + frontend/src/emulator/debugUtils.ts | 75 + frontend/src/emulator/debugger.css | 321 + frontend/src/emulator/engine.ts | 49 + frontend/src/emulator/wasmLoader.ts | 107 + frontend/src/examples/ExamplesDialog.tsx | 85 + frontend/src/examples/examples.css | 69 + frontend/src/examples/examples.test.ts | 123 + frontend/src/examples/index.ts | 45 + frontend/src/globals.d.ts | 1 + frontend/src/index.css | 99 + frontend/src/isa/InstructionEditor.tsx | 293 + frontend/src/isa/IsaDesigner.tsx | 182 + frontend/src/isa/MicroOpEditor.tsx | 567 + frontend/src/isa/encoding.test.ts | 74 + frontend/src/isa/encoding.ts | 58 + frontend/src/isa/isa.css | 376 + frontend/src/isa/isaModel.ts | 91 + frontend/src/isa/isaStore.test.ts | 91 + frontend/src/isa/isaStore.ts | 188 + frontend/src/isa/microOpDefaults.ts | 50 + frontend/src/isa/validateIsa.test.ts | 219 + frontend/src/isa/validateIsa.ts | 302 + frontend/src/machine/compileGraph.test.ts | 124 + frontend/src/machine/compileGraph.ts | 155 + frontend/src/machine/conformance.test.ts | 22 + frontend/src/machine/conformance.ts | 147 + frontend/src/machine/cycleReference.ts | 89 + frontend/src/machine/interpret.test.ts | 124 + frontend/src/machine/interpret.ts | 298 + frontend/src/machine/machineModel.ts | 50 + frontend/src/machine/microOps.ts | 112 + frontend/src/machine/validateGraph.test.ts | 126 + frontend/src/machine/validateGraph.ts | Bin 0 -> 4813 bytes frontend/src/main.tsx | 13 + frontend/src/model/fileIO.ts | 42 + frontend/src/model/history.test.ts | 154 + frontend/src/model/history.ts | 100 + frontend/src/model/migrations.test.ts | 92 + frontend/src/model/migrations.ts | 60 + frontend/src/model/persistence.test.ts | 78 + frontend/src/model/persistence.ts | 90 + frontend/src/model/projectSchema.test.ts | 123 + frontend/src/model/projectSchema.ts | 122 + frontend/src/model/serialize.test.ts | 166 + frontend/src/model/serialize.ts | 157 + frontend/src/ui/Button.tsx | 16 + frontend/src/ui/PanelHeader.tsx | 17 + frontend/src/ui/Tabs.tsx | 33 + frontend/src/ui/icons.tsx | 97 + frontend/src/ui/ui.css | 114 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 30 + scripts/build-wasm.sh | 42 + scripts/run-conformance-node.mjs | 110 + wasm/CMakeLists.txt | 59 + wasm/include/webmetal/engine.h | 130 + wasm/include/webmetal/ir.h | 129 + wasm/src/bindings.cpp | 28 + wasm/src/engine.cpp | 603 + wasm/src/ir.cpp | 354 + wasm/tests/conformance_test.cpp | 211 + wasm/tests/doctest.h | 7134 ++++++ wasm/tests/engine_test.cpp | 207 + wasm/third_party/nlohmann/json.hpp | 25526 +++++++++++++++++++ 125 files changed, 51873 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conformance/microop-vectors.json create mode 100644 examples/edu-core/edu-core.webmetal.json create mode 100644 examples/retro-8/retro-8.webmetal.json create mode 100644 examples/toy-cpu/toy-cpu.webmetal.json create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/app/AboutDialog.tsx create mode 100644 frontend/src/app/AppShell.tsx create mode 100644 frontend/src/app/HelpDialog.tsx create mode 100644 frontend/src/app/Toolbar.tsx create mode 100644 frontend/src/app/meta.test.ts create mode 100644 frontend/src/app/meta.ts create mode 100644 frontend/src/app/panels.tsx create mode 100644 frontend/src/app/shell.css create mode 100644 frontend/src/app/shortcuts.ts create mode 100644 frontend/src/app/store.test.ts create mode 100644 frontend/src/app/store.ts create mode 100644 frontend/src/app/theme.test.ts create mode 100644 frontend/src/app/theme.ts create mode 100644 frontend/src/app/usePanelResize.ts create mode 100644 frontend/src/asm/AsmEditor.tsx create mode 100644 frontend/src/asm/AsmPanel.tsx create mode 100644 frontend/src/asm/asm.css create mode 100644 frontend/src/asm/asmLanguage.test.ts create mode 100644 frontend/src/asm/asmLanguage.ts create mode 100644 frontend/src/asm/assembler.test.ts create mode 100644 frontend/src/asm/assembler.ts create mode 100644 frontend/src/asm/debugExtensions.ts create mode 100644 frontend/src/asm/editorNav.ts create mode 100644 frontend/src/asm/programStore.test.ts create mode 100644 frontend/src/asm/programStore.ts create mode 100644 frontend/src/asm/syntax.test.ts create mode 100644 frontend/src/asm/syntax.ts create mode 100644 frontend/src/editor/BlockNode.tsx create mode 100644 frontend/src/editor/GraphCanvas.tsx create mode 100644 frontend/src/editor/Inspector.tsx create mode 100644 frontend/src/editor/Toolbox.tsx create mode 100644 frontend/src/editor/editor.css create mode 100644 frontend/src/editor/graphRules.test.ts create mode 100644 frontend/src/editor/graphRules.ts create mode 100644 frontend/src/editor/graphStore.test.ts create mode 100644 frontend/src/editor/graphStore.ts create mode 100644 frontend/src/editor/nodeTypes.test.ts create mode 100644 frontend/src/editor/nodeTypes.ts create mode 100644 frontend/src/emulator/DebuggerView.tsx create mode 100644 frontend/src/emulator/debugStore.ts create mode 100644 frontend/src/emulator/debugUtils.test.ts create mode 100644 frontend/src/emulator/debugUtils.ts create mode 100644 frontend/src/emulator/debugger.css create mode 100644 frontend/src/emulator/engine.ts create mode 100644 frontend/src/emulator/wasmLoader.ts create mode 100644 frontend/src/examples/ExamplesDialog.tsx create mode 100644 frontend/src/examples/examples.css create mode 100644 frontend/src/examples/examples.test.ts create mode 100644 frontend/src/examples/index.ts create mode 100644 frontend/src/globals.d.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/isa/InstructionEditor.tsx create mode 100644 frontend/src/isa/IsaDesigner.tsx create mode 100644 frontend/src/isa/MicroOpEditor.tsx create mode 100644 frontend/src/isa/encoding.test.ts create mode 100644 frontend/src/isa/encoding.ts create mode 100644 frontend/src/isa/isa.css create mode 100644 frontend/src/isa/isaModel.ts create mode 100644 frontend/src/isa/isaStore.test.ts create mode 100644 frontend/src/isa/isaStore.ts create mode 100644 frontend/src/isa/microOpDefaults.ts create mode 100644 frontend/src/isa/validateIsa.test.ts create mode 100644 frontend/src/isa/validateIsa.ts create mode 100644 frontend/src/machine/compileGraph.test.ts create mode 100644 frontend/src/machine/compileGraph.ts create mode 100644 frontend/src/machine/conformance.test.ts create mode 100644 frontend/src/machine/conformance.ts create mode 100644 frontend/src/machine/cycleReference.ts create mode 100644 frontend/src/machine/interpret.test.ts create mode 100644 frontend/src/machine/interpret.ts create mode 100644 frontend/src/machine/machineModel.ts create mode 100644 frontend/src/machine/microOps.ts create mode 100644 frontend/src/machine/validateGraph.test.ts create mode 100644 frontend/src/machine/validateGraph.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/model/fileIO.ts create mode 100644 frontend/src/model/history.test.ts create mode 100644 frontend/src/model/history.ts create mode 100644 frontend/src/model/migrations.test.ts create mode 100644 frontend/src/model/migrations.ts create mode 100644 frontend/src/model/persistence.test.ts create mode 100644 frontend/src/model/persistence.ts create mode 100644 frontend/src/model/projectSchema.test.ts create mode 100644 frontend/src/model/projectSchema.ts create mode 100644 frontend/src/model/serialize.test.ts create mode 100644 frontend/src/model/serialize.ts create mode 100644 frontend/src/ui/Button.tsx create mode 100644 frontend/src/ui/PanelHeader.tsx create mode 100644 frontend/src/ui/Tabs.tsx create mode 100644 frontend/src/ui/icons.tsx create mode 100644 frontend/src/ui/ui.css create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100755 scripts/build-wasm.sh create mode 100644 scripts/run-conformance-node.mjs create mode 100644 wasm/CMakeLists.txt create mode 100644 wasm/include/webmetal/engine.h create mode 100644 wasm/include/webmetal/ir.h create mode 100644 wasm/src/bindings.cpp create mode 100644 wasm/src/engine.cpp create mode 100644 wasm/src/ir.cpp create mode 100644 wasm/tests/conformance_test.cpp create mode 100644 wasm/tests/doctest.h create mode 100644 wasm/tests/engine_test.cpp create mode 100644 wasm/third_party/nlohmann/json.hpp diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f98774d --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c94a93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c336fbc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8887b2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18f2848 --- /dev/null +++ b/README.md @@ -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). diff --git a/conformance/microop-vectors.json b/conformance/microop-vectors.json new file mode 100644 index 0000000..57c313f --- /dev/null +++ b/conformance/microop-vectors.json @@ -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" + } + ] +} diff --git a/examples/edu-core/edu-core.webmetal.json b/examples/edu-core/edu-core.webmetal.json new file mode 100644 index 0000000..76a2aeb --- /dev/null +++ b/examples/edu-core/edu-core.webmetal.json @@ -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" + } + ] +} diff --git a/examples/retro-8/retro-8.webmetal.json b/examples/retro-8/retro-8.webmetal.json new file mode 100644 index 0000000..f70a032 --- /dev/null +++ b/examples/retro-8/retro-8.webmetal.json @@ -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" + } + ] +} diff --git a/examples/toy-cpu/toy-cpu.webmetal.json b/examples/toy-cpu/toy-cpu.webmetal.json new file mode 100644 index 0000000..d4d44fc --- /dev/null +++ b/examples/toy-cpu/toy-cpu.webmetal.json @@ -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" + } + ] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..14f3087 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +package-lock.json +# Emscripten build output (scripts/build-wasm.sh) +public/wasm/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..b2095be --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0cbc25e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + + + WebMetal + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c5b2908 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2170 @@ +{ + "name": "webmetal-frontend", + "version": "0.14.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webmetal-frontend", + "version": "0.14.0", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..879e32f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6b5293a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + WM + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..fc5a638 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,7 @@ +import { AppShell } from './app/AppShell' + +function App() { + return +} + +export default App diff --git a/frontend/src/app/AboutDialog.tsx b/frontend/src/app/AboutDialog.tsx new file mode 100644 index 0000000..ba38ed5 --- /dev/null +++ b/frontend/src/app/AboutDialog.tsx @@ -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 , so Esc/backdrop behavior is free). */ +export function AboutDialog() { + const open = useAppStore((s) => s.aboutOpen) + const setAboutOpen = useAppStore((s) => s.setAboutOpen) + const ref = useRef(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 ( + setAboutOpen(false)} + aria-labelledby="about-title" + > + +

{appTitle(__APP_VERSION__)}

+

{APP_TAGLINE}

+

+ {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. +

+

+ Open source under the MIT license. Developed by ApfelTeeSaft +

+

+ Emulator engine: {engineStatus} +

+
+ +
+
+ ) +} diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx new file mode 100644 index 0000000..998559c --- /dev/null +++ b/frontend/src/app/AppShell.tsx @@ -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 ( +
+ + +
+ {view === 'architecture' ? : null} +
+ {/* The canvas stays mounted in ISA view to preserve its state; + it is simply hidden. */} +
+ +
+ {view === 'isa' ? : null} + {view === 'run' ? : null} + {view === 'architecture' || view === 'run' ? : null} +
+ +
+
+ + + +
+ ) +} diff --git a/frontend/src/app/HelpDialog.tsx b/frontend/src/app/HelpDialog.tsx new file mode 100644 index 0000000..c6c7d2d --- /dev/null +++ b/frontend/src/app/HelpDialog.tsx @@ -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 ). */ + +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(null) + + useEffect(() => { + const dialog = ref.current + if (!dialog) return + if (open && !dialog.open) dialog.showModal() + if (!open && dialog.open) dialog.close() + }, [open]) + + return ( + setHelpOpen(false)} + aria-labelledby="help-title" + data-testid="help-dialog" + > +

Getting started

+
    +
  1. + Design the machine. 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. +
  2. +
  3. + Define the instruction set. 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…). +
  4. +
  5. + Write a program. The Assembly panel highlights and + checks your code against the instruction set you just defined. +
  6. +
  7. + Run it. 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. +
  8. +
  9. + Keep it. Your work autosaves in the browser; Export + writes a single .webmetal.json file that Import (or drag + & drop) restores exactly - layout included. +
  10. +
+

+ New here? Open a bundled example - a complete, documented CPU you can + run immediately and take apart.{' '} + +

+ +

Keyboard shortcuts

+ + + {SHORTCUTS.map((row) => ( + + + + + ))} + +
+ {row.keys.map((k, i) => ( + + {i > 0 ? ' or ' : null} + {k} + + ))} + {row.action}
+

+ You can find the source code in the + repository's{' '} + + main branch + + . +

+ +
+ + +
+
+ ) +} diff --git a/frontend/src/app/Toolbar.tsx b/frontend/src/app/Toolbar.tsx new file mode 100644 index 0000000..824a2d7 --- /dev/null +++ b/frontend/src/app/Toolbar.tsx @@ -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(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) => { + 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 ( +
+
+ + {APP_NAME} + + {projectName} + +
+ +
+ + + +
+ +
+ + + + + + + + +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/app/meta.test.ts b/frontend/src/app/meta.test.ts new file mode 100644 index 0000000..7dedf9c --- /dev/null +++ b/frontend/src/app/meta.test.ts @@ -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) + }) +}) diff --git a/frontend/src/app/meta.ts b/frontend/src/app/meta.ts new file mode 100644 index 0000000..29f137d --- /dev/null +++ b/frontend/src/app/meta.ts @@ -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}` +} diff --git a/frontend/src/app/panels.tsx b/frontend/src/app/panels.tsx new file mode 100644 index 0000000..0f537c8 --- /dev/null +++ b/frontend/src/app/panels.tsx @@ -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 ( +
+ + {label} +
+ ) +} + +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 ( + togglePanel('toolbox')} + /> + ) + } + + return ( + <> + +
+ + ) +} + +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 ( + togglePanel('inspector')} + /> + ) + } + + return ( + <> +
+ + + ) +} + +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 ( +
+ + Assembly & Output +
+ ) + } + + return ( + <> +
+
+
+ + +
+ {/* Keep the editor mounted while Output is shown so its state + (undo history, cursor) survives tab switches. */} +
+ setTab('output')} /> +
+ {tab === 'output' ? ( + { + setTab('assembly') + // Wait for the tab switch to unhide the editor before jumping. + requestAnimationFrame(() => goToLine(line)) + }} + /> + ) : null} +
+ + ) +} diff --git a/frontend/src/app/shell.css b/frontend/src/app/shell.css new file mode 100644 index 0000000..735b4f6 --- /dev/null +++ b/frontend/src/app/shell.css @@ -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; +} diff --git a/frontend/src/app/shortcuts.ts b/frontend/src/app/shortcuts.ts new file mode 100644 index 0000000..6c5cad6 --- /dev/null +++ b/frontend/src/app/shortcuts.ts @@ -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) + }, []) +} diff --git a/frontend/src/app/store.test.ts b/frontend/src/app/store.test.ts new file mode 100644 index 0000000..bce51f1 --- /dev/null +++ b/frontend/src/app/store.test.ts @@ -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) + }) +}) diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts new file mode 100644 index 0000000..f64e22d --- /dev/null +++ b/frontend/src/app/store.ts @@ -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 + /** Panel size in px (width for side panels, height for the bottom panel). */ + panelSize: Record + 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) => void +} + +export type AppState = UiSlice & ProjectSlice + +export const useAppStore = create()((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 } })), +})) diff --git a/frontend/src/app/theme.test.ts b/frontend/src/app/theme.test.ts new file mode 100644 index 0000000..e53a898 --- /dev/null +++ b/frontend/src/app/theme.test.ts @@ -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') + }) +}) diff --git a/frontend/src/app/theme.ts b/frontend/src/app/theme.ts new file mode 100644 index 0000000..0048395 --- /dev/null +++ b/frontend/src/app/theme.ts @@ -0,0 +1,58 @@ +/** + * Light/dark theme handling. + * + * The active theme lives as `data-theme` on . 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. + } +} diff --git a/frontend/src/app/usePanelResize.ts b/frontend/src/app/usePanelResize.ts new file mode 100644 index 0000000..54fab6b --- /dev/null +++ b/frontend/src/app/usePanelResize.ts @@ -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) => { + 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) => { + 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 } +} diff --git a/frontend/src/asm/AsmEditor.tsx b/frontend/src/asm/AsmEditor.tsx new file mode 100644 index 0000000..b6db9d1 --- /dev/null +++ b/frontend/src/asm/AsmEditor.tsx @@ -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(null) + const viewRef = useRef(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
+} diff --git a/frontend/src/asm/AsmPanel.tsx b/frontend/src/asm/AsmPanel.tsx new file mode 100644 index 0000000..1b98f1f --- /dev/null +++ b/frontend/src/asm/AsmPanel.tsx @@ -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 ( +
+ + + + + + +
+ ) +} + +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 ( +
+ + {active ? ( + + ) : ( +
+

No programs in this project - add one with “+”.

+
+ )} +
+ ) +} + +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 ( +
+

Nothing assembled yet - press Assemble in the Assembly tab.

+
+ ) + } + + 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 ( +
+

+ {result.ok + ? `✓ "${program?.name ?? '?'}" assembled: ${totalWords} word(s) in ${result.segments.length} segment(s).` + : `✗ "${program?.name ?? '?'}" failed to assemble: ${errors.length} error(s).`} +

+ + {result.diagnostics.length > 0 ? ( +
    + {[...errors, ...warnings].map((d, i) => ( +
  • + +
  • + ))} +
+ ) : null} + + {result.ok && result.segments.length > 0 ? ( +
+ {result.segments.map((segment) => ( +
+ + @{segment.address.toString(16).toUpperCase().padStart(4, '0')} + + + {segment.values.map((v, i) => ( + + {hex(v)} + + ))} + +
+ ))} + {Object.keys(result.labels).length > 0 ? ( +

+ labels:{' '} + {Object.entries(result.labels) + .map( + ([name, addr]) => + `${name}=0x${addr.toString(16).toUpperCase()}`, + ) + .join(' ')} +

+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/asm/asm.css b/frontend/src/asm/asm.css new file mode 100644 index 0000000..489335c --- /dev/null +++ b/frontend/src/asm/asm.css @@ -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; +} diff --git a/frontend/src/asm/asmLanguage.test.ts b/frontend/src/asm/asmLanguage.test.ts new file mode 100644 index 0000000..772c640 --- /dev/null +++ b/frontend/src/asm/asmLanguage.test.ts @@ -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') + }) +}) diff --git a/frontend/src/asm/asmLanguage.ts b/frontend/src/asm/asmLanguage.ts new file mode 100644 index 0000000..7ca2a64 --- /dev/null +++ b/frontend/src/asm/asmLanguage.ts @@ -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 + registers: Set + directives: Set +} + +/** 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)' }, + ]), +) diff --git a/frontend/src/asm/assembler.test.ts b/frontend/src/asm/assembler.test.ts new file mode 100644 index 0000000..cdadbdd --- /dev/null +++ b/frontend/src/asm/assembler.test.ts @@ -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 { + 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') + }) +}) diff --git a/frontend/src/asm/assembler.ts b/frontend/src/asm/assembler.ts new file mode 100644 index 0000000..0bb5c35 --- /dev/null +++ b/frontend/src/asm/assembler.ts @@ -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 +} + +// --------------------------------------------------------------------------- + +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() + 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(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_]*$/ diff --git a/frontend/src/asm/debugExtensions.ts b/frontend/src/asm/debugExtensions.ts new file mode 100644 index 0000000..cb6abae --- /dev/null +++ b/frontend/src/asm/debugExtensions.ts @@ -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() + +const breakpointField = StateField.define>({ + 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() + +const execLineDecoration = Decoration.line({ class: 'cm-exec-line' }) + +export const execLineField = StateField.define({ + 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), +}) diff --git a/frontend/src/asm/editorNav.ts b/frontend/src/asm/editorNav.ts new file mode 100644 index 0000000..ff6fe5c --- /dev/null +++ b/frontend/src/asm/editorNav.ts @@ -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) }) +} diff --git a/frontend/src/asm/programStore.test.ts b/frontend/src/asm/programStore.test.ts new file mode 100644 index 0000000..bbea888 --- /dev/null +++ b/frontend/src/asm/programStore.test.ts @@ -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() + }) +}) diff --git a/frontend/src/asm/programStore.ts b/frontend/src/asm/programStore.ts new file mode 100644 index 0000000..3883d17 --- /dev/null +++ b/frontend/src/asm/programStore.ts @@ -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()((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 + ) +} diff --git a/frontend/src/asm/syntax.test.ts b/frontend/src/asm/syntax.test.ts new file mode 100644 index 0000000..7a6eed4 --- /dev/null +++ b/frontend/src/asm/syntax.test.ts @@ -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() + }) +}) diff --git a/frontend/src/asm/syntax.ts b/frontend/src/asm/syntax.ts new file mode 100644 index 0000000..8cbda87 --- /dev/null +++ b/frontend/src/asm/syntax.ts @@ -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 +} diff --git a/frontend/src/editor/BlockNode.tsx b/frontend/src/editor/BlockNode.tsx new file mode 100644 index 0000000..442b109 --- /dev/null +++ b/frontend/src/editor/BlockNode.tsx @@ -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 ( +
+ + {port.label} +
+ ) +} + +export const BlockNode = memo(function BlockNode({ + type, + data, + selected, +}: NodeProps) { + 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 ( +
+
+ {def.label} + {data.name} +
+
+
+ {inputs.map((port) => ( + + ))} +
+
+ {summary ? ( + {summary} + ) : null} +
+
+ {outputs.map((port) => ( + + ))} +
+
+ {data.doc ? ( +
+ {data.doc} +
+ ) : null} +
+ ) +}) + +/** Sticky-note comment. Text is edited inline (textarea) or in the inspector. */ +export const CommentNode = memo(function CommentNode({ + id, + data, + selected, +}: NodeProps) { + const updateNodeData = useGraphStore((s) => s.updateNodeData) + return ( +
+