├── .cursorignore ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── gh-pages.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── launch.json └── settings.json ├── .yarn ├── patches │ ├── ackee-tracker-npm-5.1.0-0db5cc0193.patch │ └── rxjs-npm-8.0.0-alpha.14-00c47179bc.patch ├── plugins │ └── @yarnpkg │ │ └── plugin-nolyfill.cjs └── releases │ └── yarn-4.5.3.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── __tests__ ├── common │ ├── error.test.ts │ └── utils │ │ ├── classNames.test.ts │ │ ├── common.test.ts │ │ ├── merge.test.ts │ │ └── mergeSafe.test.ts ├── features │ ├── __snapshots__ │ │ ├── cpu.test.ts.snap │ │ └── memory.test.ts.snap │ ├── assembler │ │ ├── __snapshots__ │ │ │ ├── index.test.ts.snap │ │ │ ├── parser.test.ts.snap │ │ │ └── tokenizer.test.ts.snap │ │ ├── index.test.ts │ │ ├── parser.test.ts │ │ └── tokenizer.test.ts │ ├── cpu.test.ts │ └── memory.test.ts ├── rawTransformer.js └── snapshotSerializers.ts ├── eslint.config.js ├── index.html ├── jest.config.js ├── package.json ├── public ├── apple-touch-icon.png ├── favicon.ico ├── pwa-192x192.png └── pwa-512x512.png ├── scripts ├── splitVendorChunk.d.ts └── splitVendorChunk.js ├── src ├── app │ ├── App.tsx │ ├── ReloadPrompt.tsx │ ├── ResizablePanel.tsx │ ├── hooks.ts │ └── store │ │ ├── enhancers │ │ ├── getStateWithSelector.ts │ │ ├── injectStoreExtension.ts │ │ └── subscribeChange.ts │ │ ├── index.ts │ │ ├── observers │ │ ├── actionObserver.ts │ │ ├── stateObserver.ts │ │ └── weakMemo.ts │ │ ├── persistence │ │ ├── combinedProvider.ts │ │ ├── index.ts │ │ ├── providers │ │ │ ├── localStorage.ts │ │ │ └── queryParam.ts │ │ └── types.ts │ │ └── selector │ │ ├── index.ts │ │ └── useSyncExternalStoreWithSelector.ts ├── common │ ├── components │ │ ├── Anchor.tsx │ │ ├── CardHeader.tsx │ │ ├── Modal.tsx │ │ ├── ResizablePanel.tsx │ │ └── icons │ │ │ ├── Arrow.tsx │ │ │ ├── ArrowDown.tsx │ │ │ ├── ArrowUp.tsx │ │ │ ├── CheckMark.tsx │ │ │ ├── Close.tsx │ │ │ ├── File.tsx │ │ │ ├── Forward.tsx │ │ │ ├── Github.tsx │ │ │ ├── Help.tsx │ │ │ ├── Play.tsx │ │ │ ├── Share.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── Stop.tsx │ │ │ ├── Undo.tsx │ │ │ ├── View.tsx │ │ │ ├── Wrench.tsx │ │ │ ├── index.ts │ │ │ └── types.ts │ ├── constants.ts │ ├── error.ts │ ├── hooks.ts │ ├── maybe.ts │ ├── observe.ts │ └── utils │ │ ├── classNames.ts │ │ ├── common.ts │ │ ├── context.ts │ │ ├── index.ts │ │ ├── invariant.ts │ │ ├── merge.ts │ │ ├── mergeSafe.ts │ │ └── types.ts ├── core │ ├── README.md │ ├── assembler │ │ ├── README.md │ │ ├── __snapshots__ │ │ │ └── parser.spec.ts.snap │ │ ├── asm.ebnf │ │ ├── assembler.spec.ts │ │ ├── assembler.state.ts │ │ ├── assembler.ts │ │ ├── assembler.utils.ts │ │ ├── assemblyunit.ts │ │ ├── ast.ts │ │ ├── errors.ts │ │ ├── instrset.ts │ │ ├── instrset.utils.ts │ │ ├── lexer.spec.ts │ │ ├── lexer.ts │ │ ├── parser.context.ts │ │ ├── parser.spec.ts │ │ ├── parser.ts │ │ ├── parser.utils.ts │ │ ├── token.stream.ts │ │ ├── token.ts │ │ └── utils.ts │ ├── bus │ │ ├── README.md │ │ └── bus.ts │ ├── clock │ │ ├── README.md │ │ └── clock.ts │ ├── controller │ │ ├── README.md │ │ ├── controller.spec.ts │ │ └── controller.ts │ ├── cpu │ │ ├── README.md │ │ └── cpu.ts │ └── memory │ │ ├── README.md │ │ └── memory.ts ├── features │ ├── assembler │ │ ├── assemble.ts │ │ ├── assemblerSlice.ts │ │ └── core │ │ │ ├── exceptions.ts │ │ │ ├── index.ts │ │ │ ├── parser.ts │ │ │ ├── tokenizer.ts │ │ │ └── types.ts │ ├── controller │ │ ├── ConfigurationMenu.tsx │ │ ├── ControlButtons.tsx │ │ ├── FileMenu.tsx │ │ ├── HelpMenu.tsx │ │ ├── Menu.tsx │ │ ├── MenuButton.tsx │ │ ├── MenuItem.tsx │ │ ├── MenuItems.tsx │ │ ├── Toolbar.tsx │ │ ├── ViewMenu.tsx │ │ ├── controllerSlice.ts │ │ ├── core │ │ │ └── index.ts │ │ ├── hooks.ts │ │ └── selectors.ts │ ├── cpu │ │ ├── CpuRegisters.tsx │ │ ├── RegisterTableRow.tsx │ │ ├── RegisterValueTableCell.tsx │ │ ├── core │ │ │ ├── changes.ts │ │ │ ├── constants.ts │ │ │ ├── exceptions.ts │ │ │ ├── index.ts │ │ │ └── operations.ts │ │ └── cpuSlice.ts │ ├── editor │ │ ├── CodeMirror.tsx │ │ ├── Editor.tsx │ │ ├── EditorMessage.tsx │ │ ├── codemirror │ │ │ ├── annotations.ts │ │ │ ├── asm.ts │ │ │ ├── breakpoints.ts │ │ │ ├── classNames.ts │ │ │ ├── exceptionSink.ts │ │ │ ├── gutter.ts │ │ │ ├── highlightActiveLine.ts │ │ │ ├── highlightActiveLineGutter.ts │ │ │ ├── highlightLine.ts │ │ │ ├── highlightSelectionMatches.ts │ │ │ ├── indentWithTab.ts │ │ │ ├── lineNumbers.ts │ │ │ ├── observable.ts │ │ │ ├── rangeSet.ts │ │ │ ├── setup.ts │ │ │ ├── state.ts │ │ │ ├── text.ts │ │ │ ├── theme.ts │ │ │ ├── vim.ts │ │ │ └── wavyUnderline.ts │ │ ├── editorSlice.ts │ │ ├── effects.ts │ │ ├── examples │ │ │ ├── hardware_interrupts.asm │ │ │ ├── index.ts │ │ │ ├── keyboard_input.asm │ │ │ ├── procedures.asm │ │ │ ├── seven_segment_display.asm │ │ │ ├── software_interrupts.asm │ │ │ ├── template.asm │ │ │ ├── traffic_lights.asm │ │ │ └── visual_display_unit.asm │ │ ├── hooks.ts │ │ └── selectors.ts │ ├── exception │ │ ├── ErrorBoundary.tsx │ │ ├── ExceptionModal.tsx │ │ ├── exceptionSlice.ts │ │ └── hooks.ts │ ├── io │ │ ├── DeviceCard.tsx │ │ ├── IoDevices.tsx │ │ ├── SevenSegmentDisplay.tsx │ │ ├── SimulatedKeyboard.tsx │ │ ├── TrafficLights.tsx │ │ ├── VisualDisplayUnit.tsx │ │ ├── core.ts │ │ ├── hooks.ts │ │ └── ioSlice.ts │ └── memory │ │ ├── Memory.tsx │ │ ├── core.ts │ │ ├── memorySlice.ts │ │ └── selectors.ts ├── main.tsx ├── styles.css ├── ts-reset.d.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.test.json ├── uno.config.ts ├── vite.config.ts ├── vitest.config.ts └── yarn.lock /.cursorignore: -------------------------------------------------------------------------------- 1 | .yarn/ 2 | __snapshots__/ 3 | 4 | LICENSE 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.asm] 12 | indent_style = tab 13 | indent_size = 4 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | tsconfig.*json linguist-language=JSON-with-Comments 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [exuanbo] 2 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version-file: .nvmrc 16 | check-latest: true 17 | cache: yarn 18 | - run: yarn install 19 | - run: yarn build 20 | - uses: peaceiris/actions-gh-pages@v3 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | publish_dir: ./dist 24 | force_orphan: true 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: .nvmrc 19 | check-latest: true 20 | cache: yarn 21 | - run: yarn install 22 | - run: yarn lint:ci 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | push: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - '**.md' 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: .nvmrc 23 | check-latest: true 24 | cache: yarn 25 | - run: yarn install 26 | - run: yarn test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node # 2 | ######## 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | # Custom # 136 | ########## 137 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "vscode-jest-tests.v2", 6 | "request": "launch", 7 | "args": [ 8 | "--coverage=false", 9 | "--runInBand", 10 | "--watchAll=false", 11 | "--testNamePattern", 12 | "${jest.testNamePattern}", 13 | "--runTestsByPath", 14 | "${jest.testFile}" 15 | ], 16 | "cwd": "${workspaceFolder}", 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "program": "${workspaceFolder}/node_modules/.bin/jest" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "cSpell.words": ["immer", "pako", "reduxjs", "unocss"] 4 | } 5 | -------------------------------------------------------------------------------- /.yarn/patches/ackee-tracker-npm-5.1.0-0db5cc0193.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/scripts/main.js b/src/scripts/main.js 2 | index 47294e9b4f4f2dc6628d7e0549028bc11c5a0712..9a3c03ec47ff26e35dac3d51a19156c221fbf262 100644 3 | --- a/src/scripts/main.js 4 | +++ b/src/scripts/main.js 5 | @@ -429,10 +429,3 @@ export const create = function(server, opts) { 6 | } 7 | 8 | } 9 | - 10 | -// Only run Ackee automatically when executed in a browser environment 11 | -if (isBrowser === true) { 12 | - 13 | - detect() 14 | - 15 | -} 16 | \ No newline at end of file 17 | -------------------------------------------------------------------------------- /.yarn/patches/rxjs-npm-8.0.0-alpha.14-00c47179bc.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/types/internal/operators/filter.d.ts b/dist/types/internal/operators/filter.d.ts 2 | index 01089a216c3214a5d2db80d6ce3867212d6278e4..ccc6db28c58f78e2d6c4699c70c3dbfc42863cac 100644 3 | --- a/dist/types/internal/operators/filter.d.ts 4 | +++ b/dist/types/internal/operators/filter.d.ts 5 | @@ -5,5 +5,5 @@ export declare function filter(predicate: (value: T, index: numb 6 | export declare function filter(predicate: BooleanConstructor): OperatorFunction>; 7 | /** @deprecated Use a closure instead of a `thisArg`. Signatures accepting a `thisArg` will be removed in v8. */ 8 | export declare function filter(predicate: (this: A, value: T, index: number) => boolean, thisArg: A): MonoTypeOperatorFunction; 9 | -export declare function filter(predicate: (value: T, index: number) => boolean): MonoTypeOperatorFunction; 10 | +export declare function filter(predicate: (value: T, index: number) => unknown): MonoTypeOperatorFunction; 11 | //# sourceMappingURL=filter.d.ts.map 12 | \ No newline at end of file 13 | diff --git a/src/internal/operators/filter.ts b/src/internal/operators/filter.ts 14 | index 4c99edfdcacab7fcbd563fe9634ef6beeeae0a71..9aaf2262c9fa71c9d8e86ee07bfec2d4889973a7 100644 15 | --- a/src/internal/operators/filter.ts 16 | +++ b/src/internal/operators/filter.ts 17 | @@ -7,7 +7,7 @@ export function filter(predicate: (value: T, index: number) => v 18 | export function filter(predicate: BooleanConstructor): OperatorFunction>; 19 | /** @deprecated Use a closure instead of a `thisArg`. Signatures accepting a `thisArg` will be removed in v8. */ 20 | export function filter(predicate: (this: A, value: T, index: number) => boolean, thisArg: A): MonoTypeOperatorFunction; 21 | -export function filter(predicate: (value: T, index: number) => boolean): MonoTypeOperatorFunction; 22 | +export function filter(predicate: (value: T, index: number) => unknown): MonoTypeOperatorFunction; 23 | 24 | /** 25 | * Filter items emitted by the source Observable by only emitting those that 26 | @@ -57,7 +57,7 @@ export function filter(predicate: (value: T, index: number) => boolean): Mono 27 | * @return A function that returns an Observable that emits items from the 28 | * source Observable that satisfy the specified `predicate`. 29 | */ 30 | -export function filter(predicate: (value: T, index: number) => boolean, thisArg?: any): MonoTypeOperatorFunction { 31 | +export function filter(predicate: (value: T, index: number) => unknown, thisArg?: any): MonoTypeOperatorFunction { 32 | return (source) => 33 | new Observable((destination) => { 34 | // An index passed to our predicate function on each call. 35 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-nolyfill.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-nolyfill", 5 | factory: function (require) { 6 | "use strict";var plugin=(()=>{var p=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var n=Object.getOwnPropertyNames;var y=Object.prototype.hasOwnProperty;var l=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(r,e)=>(typeof require<"u"?require:r)[e]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var c=(t,r)=>{for(var e in r)p(t,e,{get:r[e],enumerable:!0})},f=(t,r,e,s)=>{if(r&&typeof r=="object"||typeof r=="function")for(let a of n(r))!y.call(t,a)&&a!==e&&p(t,a,{get:()=>r[a],enumerable:!(s=i(r,a))||s.enumerable});return t};var g=t=>f(p({},"__esModule",{value:!0}),t);var h={};c(h,{default:()=>m});var o=l("@yarnpkg/core"),d=["abab","array-buffer-byte-length","array-flatten","array-includes","array.from","array.of","array.prototype.at","array.prototype.every","array.prototype.find","array.prototype.findlast","array.prototype.findlastindex","array.prototype.flat","array.prototype.flatmap","array.prototype.flatmap","array.prototype.foreach","array.prototype.reduce","array.prototype.toreversed","array.prototype.tosorted","arraybuffer.prototype.slice","assert","asynciterator.prototype","available-typed-arrays","deep-equal","deep-equal-json","define-properties","es-aggregate-error","es-iterator-helpers","es-set-tostringtag","es6-object-assign","function-bind","function.prototype.name","get-symbol-description","globalthis","gopd","harmony-reflect","has","has-property-descriptors","has-proto","has-symbols","has-tostringtag","hasown","internal-slot","is-arguments","is-array-buffer","is-core-module","is-date-object","is-generator-function","is-nan","is-regex","is-shared-array-buffer","is-string","is-symbol","is-typed-array","is-weakref","isarray","iterator.prototype","json-stable-stringify","jsonify","number-is-nan","object-is","object-keys","object.assign","object.entries","object.fromentries","object.getownpropertydescriptors","object.groupby","object.hasown","object.values","promise.allsettled","promise.any","reflect.getprototypeof","reflect.ownkeys","regexp.prototype.flags","safe-array-concat","safe-buffer","safe-regex-test","safer-buffer","set-function-length","side-channel","string.prototype.at","string.prototype.codepointat","string.prototype.includes","string.prototype.matchall","string.prototype.padend","string.prototype.padstart","string.prototype.repeat","string.prototype.replaceall","string.prototype.split","string.prototype.startswith","string.prototype.trim","string.prototype.trimend","string.prototype.trimleft","string.prototype.trimright","string.prototype.trimstart","typed-array-buffer","typed-array-byte-length","typed-array-byte-offset","typed-array-length","typedarray","typedarray.prototype.slice","unbox-primitive","util.promisify","which-boxed-primitive","which-typed-array"],u=new Map(d.map(t=>[o.structUtils.makeIdent(null,t).identHash,o.structUtils.makeIdent("nolyfill",t)])),b={hooks:{reduceDependency:async t=>{let r=u.get(t.identHash);if(r){let e=o.structUtils.makeDescriptor(r,"latest"),s=o.structUtils.makeRange({protocol:"npm:",source:null,selector:o.structUtils.stringifyDescriptor(e),params:null});return o.structUtils.makeDescriptor(t,s)}return t}}},m=b;return g(h);})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nmMode: hardlinks-local 2 | 3 | nodeLinker: node-modules 4 | 5 | npmRegistryServer: "https://registry.npmjs.org/" 6 | 7 | plugins: 8 | - checksum: b023a66bd5b071f92c561b1a3815c12f462f00ec92647f54d7ad88ae81c35cb4f25e70b6664112edcf33fef17b7b4daf55a2fd7a2abe57b7699c1805d9116f43 9 | path: .yarn/plugins/@yarnpkg/plugin-nolyfill.cjs 10 | spec: "https://raw.githubusercontent.com/wojtekmaj/yarn-plugin-nolyfill/v1.0.3/bundles/@yarnpkg/plugin-nolyfill.js" 11 | 12 | yarnPath: .yarn/releases/yarn-4.5.3.cjs 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Assembler Simulator 2 | 3 | [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/exuanbo/assembler-simulator.svg?label=release&sort=semver)](https://github.com/exuanbo/assembler-simulator/tags) 4 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/exuanbo/assembler-simulator/gh-pages.yml.svg) 5 | [![libera manifesto](https://img.shields.io/badge/libera-manifesto-lightgrey.svg)](https://liberamanifesto.com) 6 | 7 | The Assembler Simulator is an 8-bit CPU simulation tool that utilizes the "Samphire" sms32v50 [Microprocessor Simulator](https://nbest.co.uk/Softwareforeducation/sms32v50/sms32v50_manual/index.htm) instruction set, which is analogous to the Intel 8086 architecture. Originally, a native desktop application based on this instruction set was employed at University College Cork to facilitate the teaching of the CS1111 Systems Organisation module. However, it was limited to the Windows operating system. 8 | 9 | Our project endeavors to replicate the functionality of the "Samphire" application as closely as possible, while enhancing the learning experience by leveraging state-of-the-art web technologies: 10 | 11 | - [CodeMirror 6](https://codemirror.net/6/) for code editing with syntax highlighting 12 | - [React](https://reactjs.org/) for building user interfaces 13 | - [Redux](https://redux.js.org/) for state management 14 | - [RxJS](https://rxjs.dev/) for reactive programming 15 | - [Vite](https://vitejs.dev/) for fast development and build tooling 16 | - [TypeScript](https://www.typescriptlang.org/) for static type checking 17 | 18 | Experience the Assembler Simulator online [here](https://exuanbo.xyz/assembler-simulator/). 19 | 20 | ## Features 21 | 22 | - An 8-bit CPU simulation with four general-purpose registers 23 | - A memory model with 256 bytes of RAM 24 | - Support for procedures and subroutines 25 | - Implementation of software and hardware interrupts 26 | - Keyboard input handling 27 | - A suite of output devices, including: 28 | - A memory-mapped Visual Display Unit 29 | - Simulated traffic lights 30 | - A seven-segment display 31 | - Additional devices planned for future updates 32 | - Debugging features like breakpoints and step-by-step execution 33 | - An integrated editor equipped with syntax highlighting for a seamless coding experience 34 | 35 | ## Acknowledgements 36 | 37 | This project draws inspiration from and extends gratitude to the following works: 38 | 39 | - [osslate/babassu](https://github.com/osslate/babassu) - A heartfelt thank you! 😀 40 | - [Schweigi/assembler-simulator](https://github.com/Schweigi/assembler-simulator) 41 | - [parraman/asm-simulator](https://github.com/parraman/asm-simulator) 42 | 43 | ## License 44 | 45 | [GPL-3.0 License](https://github.com/exuanbo/assembler-simulator/blob/main/LICENSE) © 2022-Present [Exuanbo](https://github.com/exuanbo) 46 | -------------------------------------------------------------------------------- /__tests__/common/error.test.ts: -------------------------------------------------------------------------------- 1 | import { errorToPlainObject } from '@/common/error' 2 | 3 | describe('ErrorObject', () => { 4 | describe('errorToPlainObject', () => { 5 | it('should return plain object', () => { 6 | const errorObject = errorToPlainObject(new Error('test')) 7 | expect(errorObject).not.toBeInstanceOf(Error) 8 | expect(Object.getPrototypeOf(errorObject)).toBe(Object.prototype) 9 | expect(errorObject).toMatchObject({ 10 | name: 'Error', 11 | message: 'test', 12 | stack: expect.any(String), 13 | }) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /__tests__/common/utils/classNames.test.ts: -------------------------------------------------------------------------------- 1 | import { classNames } from '@/common/utils' 2 | 3 | describe('classNames', () => { 4 | it('should return empty string with no arguments', () => { 5 | expect(classNames()).toEqual('') 6 | }) 7 | 8 | it('should concat strings', () => { 9 | expect(classNames('a', 'b')).toEqual('a b') 10 | }) 11 | 12 | it('should ignore undefined', () => { 13 | expect(classNames(undefined)).toEqual('') 14 | expect(classNames('a', undefined, 'b')).toEqual('a b') 15 | }) 16 | 17 | it('should concat object property with true value', () => { 18 | expect(classNames({ a: true })).toEqual('a') 19 | expect(classNames('a', { b: true }, 'c')).toEqual('a b c') 20 | }) 21 | 22 | it('should ignore object property with falsy value', () => { 23 | expect(classNames({ a: false, b: null, c: undefined })).toEqual('') 24 | expect(classNames('a', { b: false }, 'c')).toEqual('a c') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /__tests__/common/utils/mergeSafe.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeSafe } from '@/common/utils' 2 | 3 | describe('mergeSafe', () => { 4 | it('should not merge when types are different', () => { 5 | const target = { a: 1 } 6 | const source = 'string' 7 | const result = mergeSafe(target, source) 8 | expect(result).toEqual(target) 9 | }) 10 | 11 | it('should deeply merge when key from source exists in target', () => { 12 | const target = { a: { b: { c: 1 }, d: 3 } } 13 | const source = { a: { b: { c: 2 } } } 14 | const result = mergeSafe(target, source) 15 | expect(result).toEqual({ a: { b: { c: 2 }, d: 3 } }) 16 | }) 17 | 18 | it('should not merge when key from source does not exist in target', () => { 19 | const target = { a: 1 } 20 | const source = { b: 2 } 21 | const result = mergeSafe(target, source) 22 | expect(result).toEqual(target) 23 | }) 24 | 25 | it('should not deeply merge when nested key from source does not exist in target', () => { 26 | const target = { a: { b: 1 } } 27 | const source = { a: { c: 2 } } 28 | const result = mergeSafe(target, source) 29 | expect(result).toEqual(target) 30 | }) 31 | 32 | it('should not merge arrays', () => { 33 | const target = { a: [1, 2] } 34 | const source = { a: [3, 4] } 35 | const result = mergeSafe(target, source) 36 | expect(result).toEqual(source) 37 | }) 38 | 39 | it('should handle symbols as keys', () => { 40 | const key = Symbol('key') 41 | const target = { [key]: 1 } 42 | const source = { [key]: 2 } 43 | const result = mergeSafe(target, source) 44 | expect(result[key]).toBe(2) 45 | }) 46 | 47 | it('should not mutate the original objects', () => { 48 | const target = { a: 1 } 49 | const source = { a: 2 } 50 | const result = mergeSafe(target, source) 51 | expect(result).not.toBe(target) 52 | expect(result).not.toBe(source) 53 | }) 54 | 55 | it('should not deeply mutate the original objects', () => { 56 | const target = { a: { b: 1 } } 57 | const source = { a: { b: 2 } } 58 | const result = mergeSafe(target, source) 59 | expect(result.a).not.toBe(target.a) 60 | expect(result.a).not.toBe(source.a) 61 | }) 62 | 63 | it('should handle undefined and null values', () => { 64 | const target = { a: 1, b: undefined } 65 | const source = { b: null, c: 3 } 66 | const result = mergeSafe(target, source) 67 | expect(result).toEqual({ a: 1, b: undefined }) 68 | }) 69 | 70 | it('should return the target when no sources are provided', () => { 71 | const target = { a: 1 } 72 | const result = mergeSafe(target) 73 | expect(result).toEqual(target) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /__tests__/features/assembler/index.test.ts: -------------------------------------------------------------------------------- 1 | import { assemble, AssemblerError } from '@/features/assembler/core' 2 | import { examples } from '@/features/editor/examples' 3 | import { initDataFrom } from '@/features/memory/core' 4 | 5 | import { memoryDataSerializer } from '../../snapshotSerializers' 6 | 7 | expect.addSnapshotSerializer(memoryDataSerializer) 8 | 9 | describe('assembler', () => { 10 | examples.forEach(({ title, content }) => { 11 | it(`should assemble example ${title}`, () => { 12 | const [addressToMachineCodeMap] = assemble(content) 13 | expect(initDataFrom(addressToMachineCodeMap)).toMatchSnapshot() 14 | }) 15 | }) 16 | 17 | it('should throw instance of AssemblerError', () => { 18 | try { 19 | assemble('foo') 20 | } 21 | catch (error) { 22 | expect(error).toBeInstanceOf(AssemblerError) 23 | } 24 | expect.assertions(1) 25 | }) 26 | 27 | it('should throw DuplicateLabelError', () => { 28 | expect(() => { 29 | assemble(` 30 | start: inc al 31 | start: dec bl 32 | end 33 | `) 34 | }).toThrowErrorMatchingInlineSnapshot(`"Duplicate label 'START'."`) 35 | }) 36 | 37 | it('should throw EndOfMemoryError', () => { 38 | expect(() => { 39 | assemble(` 40 | org ff 41 | inc al 42 | end 43 | `) 44 | }).toThrowErrorMatchingInlineSnapshot(`"Can not generate code beyond the end of RAM."`) 45 | }) 46 | 47 | it('should throw LabelNotExistError', () => { 48 | expect(() => { 49 | assemble('jmp start end') 50 | }).toThrowErrorMatchingInlineSnapshot(`"Label 'start' does not exist."`) 51 | }) 52 | 53 | it('should throw JumpDistanceError', () => { 54 | expect(() => { 55 | assemble(` 56 | start: 57 | inc al 58 | org fd 59 | jmp start 60 | end 61 | `) 62 | }).toThrowErrorMatchingInlineSnapshot(`"Jump distance should be between -128 and 127."`) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /__tests__/features/assembler/tokenizer.test.ts: -------------------------------------------------------------------------------- 1 | import { createTokenizer, type Token } from '@/features/assembler/core/tokenizer' 2 | 3 | import { shortArraySerializer } from '../../snapshotSerializers' 4 | 5 | expect.addSnapshotSerializer(shortArraySerializer) 6 | 7 | const tokenize = (source: string): Token[] => { 8 | const tokenizer = createTokenizer(source) 9 | const tokens: Token[] = [] 10 | while (tokenizer.hasMore()) { 11 | tokens.push(tokenizer.consume()) 12 | } 13 | return tokens 14 | } 15 | 16 | describe('tokenizer', () => { 17 | it('should skip whitespace', () => { 18 | expect(tokenize(' \t\ndone: \t\nend')).toMatchSnapshot() 19 | }) 20 | 21 | it('should skip line with comment', () => { 22 | expect(tokenize('; this is a comment\nend')).toMatchSnapshot() 23 | }) 24 | 25 | it('should skip comment at the end of the line', () => { 26 | expect(tokenize('done:; this is a comment\nend; this is another comment')).toMatchSnapshot() 27 | }) 28 | 29 | it('should tokenize comma', () => { 30 | expect(tokenize(',\n,,')).toMatchSnapshot() 31 | }) 32 | 33 | it('should tokenize digits', () => { 34 | expect(tokenize('0 01 002')).toMatchSnapshot() 35 | }) 36 | 37 | it('should tokenize register', () => { 38 | expect(tokenize('al Bl cL DL')).toMatchSnapshot() 39 | }) 40 | 41 | it('should tokenize address', () => { 42 | expect(tokenize('[] [00][al] [ Bl ]')).toMatchSnapshot() 43 | }) 44 | 45 | it('should throw UnterminatedAddressError when tokenizing address if closing bracket is missing', () => { 46 | expect(() => { 47 | tokenize('[00') 48 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated address '[00'."`) 49 | 50 | expect(() => { 51 | tokenize('[al\n') 52 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated address '[al'."`) 53 | 54 | expect(() => { 55 | tokenize('[Bl ') 56 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated address '[Bl'."`) 57 | 58 | expect(() => { 59 | tokenize('[cL \n') 60 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated address '[cL'."`) 61 | 62 | expect(() => { 63 | tokenize('[ DL ;') 64 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated address '[ DL'."`) 65 | }) 66 | 67 | it('should tokenize string', () => { 68 | expect(tokenize('"" "this is a string" "\\"" "\\n"')).toMatchSnapshot() 69 | }) 70 | 71 | it('should remove invalid escape', () => { 72 | expect(tokenize('"f\\o\\o"')).toMatchSnapshot() 73 | expect(tokenize('"\\0\\u1"')).toMatchSnapshot() 74 | }) 75 | 76 | it('should throw UnterminatedStringError when tokenizing string if closing quote is missing', () => { 77 | expect(() => { 78 | tokenize('"\\"') 79 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated string '\\"\\\\\\\\\\"'."`) 80 | 81 | expect(() => { 82 | tokenize('"foo\nbar"') 83 | }).toThrowErrorMatchingInlineSnapshot(`"Unterminated string '\\"foo'."`) 84 | }) 85 | 86 | it('should throw Error when tokenizing unsupported character', () => { 87 | expect(() => { 88 | tokenize('!') 89 | }).toThrowErrorMatchingInlineSnapshot(`"Unexpected character '!'."`) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /__tests__/features/memory.test.ts: -------------------------------------------------------------------------------- 1 | import { assemble } from '@/features/assembler/core' 2 | import { getSourceFrom, getVduDataFrom, initData } from '@/features/memory/core' 3 | 4 | import { memoryDataSerializer } from '../snapshotSerializers' 5 | 6 | expect.addSnapshotSerializer(memoryDataSerializer) 7 | 8 | describe('memory', () => { 9 | describe('getVduDataFrom', () => { 10 | it('should return the VDU data', () => { 11 | const memoryData = initData() 12 | const vduData = getVduDataFrom(memoryData) 13 | expect(vduData).toMatchSnapshot() 14 | }) 15 | }) 16 | 17 | describe('getSourceFrom', () => { 18 | it('should return correct source', () => { 19 | const [, addressToStatementMap] = assemble('jmp start start: add al, 01 mov bl, [cl] end') 20 | const source = getSourceFrom(addressToStatementMap) 21 | expect(source).toMatchSnapshot() 22 | }) 23 | 24 | describe('with DB', () => { 25 | it('should display number', () => { 26 | const [, addressToStatementMap] = assemble('db a0 end') 27 | const source = getSourceFrom(addressToStatementMap) 28 | expect(source).toMatchSnapshot() 29 | }) 30 | 31 | it('should split string', () => { 32 | const [, addressToStatementMap] = assemble('db "Hello, world!" end') 33 | const source = getSourceFrom(addressToStatementMap) 34 | expect(source).toMatchSnapshot() 35 | }) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /__tests__/rawTransformer.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** @typedef {import('@jest/transform').Transformer} Transformer */ 3 | 4 | /** @type {Transformer} */ 5 | const rawTransformer = { 6 | process(sourceText) { 7 | return { 8 | code: `exports.default = ${JSON.stringify(sourceText)};`, 9 | } 10 | }, 11 | } 12 | 13 | export default rawTransformer 14 | -------------------------------------------------------------------------------- /__tests__/snapshotSerializers.ts: -------------------------------------------------------------------------------- 1 | import { chunk, decToHex } from '@/common/utils' 2 | 3 | const SEPARATOR = ', ' 4 | 5 | type TypeofResult = 6 | | 'string' 7 | | 'number' 8 | | 'bigint' 9 | | 'boolean' 10 | | 'symbol' 11 | | 'undefined' 12 | | 'object' 13 | | 'function' 14 | 15 | type TypeofResultToType = 16 | T extends 'string' ? string : 17 | T extends 'number' ? number : 18 | T extends 'bigint' ? bigint : 19 | T extends 'boolean' ? boolean : 20 | T extends 'symbol' ? symbol : 21 | T extends 'undefined' ? undefined : 22 | T extends 'object' ? object : 23 | T extends 'function' ? Function : 24 | never 25 | 26 | const isArrayOf = (...types: T[]) => 27 | (value: unknown): value is Array> => 28 | Array.isArray(value) 29 | && value.length > 0 30 | && value.every((item) => (types as TypeofResult[]).includes(typeof item)) 31 | 32 | export const shortArraySerializer: jest.SnapshotSerializerPlugin = { 33 | test: (value) => isArrayOf('number', 'boolean')(value) && value.length <= 4, 34 | serialize: (arr: Array) => `[${arr.join(SEPARATOR)},]`, 35 | } 36 | 37 | export const memoryDataSerializer: jest.SnapshotSerializerPlugin = { 38 | test: (value) => isArrayOf('number', 'string')(value) && value.length % 0x10 === 0, 39 | serialize: (arr: Array, _config, indentation) => `[ 40 | ${chunk(0x10, arr) 41 | .map( 42 | (row) => 43 | `${indentation}${' '.repeat(2)}${row 44 | .map((value) => (typeof value === 'number' ? decToHex(value) : value)) 45 | .join(SEPARATOR)}`, 46 | ) 47 | .join(',\n')}, 48 | ${indentation}]`, 49 | } 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Assembler Simulator 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** @typedef {import('ts-jest').JestConfigWithTsJest} JestConfig */ 3 | 4 | /** @type {JestConfig} */ 5 | const config = { 6 | collectCoverage: true, 7 | coveragePathIgnorePatterns: ['/src/features/editor/examples/'], 8 | moduleNameMapper: { 9 | '^@/(.*)': '/src/$1', 10 | '(.+)\\?raw$': '$1', 11 | }, 12 | // TODO: remove once Jest supports Prettier version 3 13 | // https://jestjs.io/docs/configuration#prettierpath-string 14 | prettierPath: null, 15 | snapshotFormat: { 16 | escapeString: true, 17 | }, 18 | testEnvironment: 'jsdom', 19 | testMatch: ['/__tests__/**/*.test.ts?(x)'], 20 | transform: { 21 | '\\.tsx?$': [ 22 | 'ts-jest', 23 | { 24 | tsconfig: 'tsconfig.test.json', 25 | isolatedModules: true, 26 | }, 27 | ], 28 | '\\.asm$': '/__tests__/rawTransformer.js', 29 | }, 30 | } 31 | 32 | export default config 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assembler-simulator", 3 | "private": true, 4 | "version": "1.0.0-alpha.39", 5 | "description": "A simulator of 8-bit CPU using the Samphire Microprocessor Simulator instruction set.", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "lint": "eslint . --cache --fix", 12 | "lint:ci": "eslint . --cache --cache-location ./node_modules/.cache/eslint/.eslintcache --max-warnings 0", 13 | "test": "run build && jest && vitest run", 14 | "all": "run lint && run test" 15 | }, 16 | "dependencies": { 17 | "@codemirror-toolkit/extensions": "0.6.3", 18 | "@codemirror-toolkit/react": "0.7.1", 19 | "@codemirror-toolkit/utils": "0.5.1", 20 | "@codemirror/autocomplete": "6.18.1", 21 | "@codemirror/commands": "6.6.2", 22 | "@codemirror/language": "6.10.3", 23 | "@codemirror/search": "6.5.6", 24 | "@codemirror/state": "6.4.1", 25 | "@codemirror/view": "6.34.1", 26 | "@reduxjs/toolkit": "2.2.7", 27 | "@replit/codemirror-vim": "6.2.1", 28 | "@unocss/reset": "0.63.1", 29 | "ackee-tracker": "patch:ackee-tracker@npm%3A5.1.0#~/.yarn/patches/ackee-tracker-npm-5.1.0-0db5cc0193.patch", 30 | "di-wise": "0.2.7", 31 | "immer": "npm:mutative-compat@0.1.2", 32 | "js-base64": "3.7.7", 33 | "mutative": "1.0.11", 34 | "pako": "2.1.0", 35 | "react": "18.3.1", 36 | "react-dom": "18.3.1", 37 | "rxjs": "patch:rxjs@npm%3A8.0.0-alpha.14#~/.yarn/patches/rxjs-npm-8.0.0-alpha.14-00c47179bc.patch", 38 | "ts-brand": "0.2.0", 39 | "ts-enum-utilx": "0.3.0", 40 | "ts-expect": "1.3.0", 41 | "use-external-store": "0.2.2" 42 | }, 43 | "devDependencies": { 44 | "@eslint/js": "9.11.1", 45 | "@stylistic/eslint-plugin": "2.8.0", 46 | "@types/ackee-tracker": "5.0.4", 47 | "@types/eslint__js": "8.42.3", 48 | "@types/jest": "29.5.13", 49 | "@types/node": "22.7.4", 50 | "@types/pako": "2.0.3", 51 | "@types/react": "18.3.10", 52 | "@types/react-dom": "18.3.0", 53 | "@unocss/preset-web-fonts": "0.63.1", 54 | "@unocss/preset-wind": "0.63.1", 55 | "@unocss/transformer-directives": "0.63.1", 56 | "@unocss/transformer-variant-group": "0.63.1", 57 | "@vitejs/plugin-react": "4.3.1", 58 | "@vitest/coverage-v8": "2.1.1", 59 | "@vitest/ui": "2.1.1", 60 | "eslint": "9.11.1", 61 | "eslint-plugin-react": "7.37.0", 62 | "eslint-plugin-react-hooks": "5.1.0-rc-3edc000d-20240926", 63 | "eslint-plugin-simple-import-sort": "12.1.1", 64 | "globals": "15.9.0", 65 | "jest": "29.7.0", 66 | "jest-environment-jsdom": "29.7.0", 67 | "rollup": "4.22.5", 68 | "ts-jest": "29.2.5", 69 | "ts-toolbelt": "9.6.0", 70 | "typescript": "5.5.4", 71 | "typescript-eslint": "8.7.0", 72 | "unocss": "0.63.1", 73 | "vite": "5.4.8", 74 | "vite-plugin-pwa": "0.20.5", 75 | "vitest": "2.1.1" 76 | }, 77 | "resolutions": { 78 | "immer": "npm:mutative-compat@^0.1.0" 79 | }, 80 | "packageManager": "yarn@4.5.3" 81 | } 82 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/assembler-simulator/e8d3ac7c17bf0676dd85446c77f9d9a342c6b5ac/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/assembler-simulator/e8d3ac7c17bf0676dd85446c77f9d9a342c6b5ac/public/favicon.ico -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/assembler-simulator/e8d3ac7c17bf0676dd85446c77f9d9a342c6b5ac/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/assembler-simulator/e8d3ac7c17bf0676dd85446c77f9d9a342c6b5ac/public/pwa-512x512.png -------------------------------------------------------------------------------- /scripts/splitVendorChunk.d.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite' 2 | 3 | declare function splitVendorChunk(): Plugin 4 | 5 | export default splitVendorChunk 6 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import ToolBar from '@/features/controller/Toolbar' 4 | import CpuRegisters from '@/features/cpu/CpuRegisters' 5 | import Editor from '@/features/editor/Editor' 6 | import ErrorBoundary from '@/features/exception/ErrorBoundary' 7 | import ExceptionModal from '@/features/exception/ExceptionModal' 8 | import { useGlobalExceptionHandler } from '@/features/exception/hooks' 9 | import IoDevices from '@/features/io/IoDevices' 10 | import Memory from '@/features/memory/Memory' 11 | 12 | import { useAckee } from './hooks' 13 | import ReloadPrompt from './ReloadPrompt' 14 | import ResizablePanel from './ResizablePanel' 15 | 16 | const App: FC = () => { 17 | useGlobalExceptionHandler() 18 | useAckee() 19 | 20 | return ( 21 | <> 22 |
23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /src/app/ReloadPrompt.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, type PropsWithChildren, useState } from 'react' 2 | import { useRegisterSW } from 'virtual:pwa-register/react' 3 | 4 | import { Spinner } from '@/common/components/icons' 5 | 6 | type ButtonProps = PropsWithChildren<{ 7 | onClick: React.MouseEventHandler 8 | }> 9 | 10 | const PromptButton: FC = ({ onClick, children }) => ( 11 |
14 | {children} 15 |
16 | ) 17 | 18 | const ReloadPrompt: FC = () => { 19 | const { 20 | needRefresh: [needReload, setNeedReload], 21 | updateServiceWorker, 22 | } = useRegisterSW() 23 | 24 | const [isReloading, setReloading] = useState(false) 25 | 26 | const handleClickReload = (): void => { 27 | setReloading(true) 28 | void updateServiceWorker(/* reloadPage: */ true) 29 | } 30 | 31 | const handleClickClose = (): void => { 32 | setNeedReload(false) 33 | } 34 | 35 | if (isReloading) { 36 | return ( 37 |
38 | 39 |
40 | ) 41 | } 42 | 43 | if (needReload) { 44 | return ( 45 |
46 |
New version is available
47 |
48 | Reload 49 | Close 50 |
51 |
52 | ) 53 | } 54 | 55 | return null 56 | } 57 | 58 | export default ReloadPrompt 59 | -------------------------------------------------------------------------------- /src/app/ResizablePanel.tsx: -------------------------------------------------------------------------------- 1 | import BaseResizablePanel, { DEFAULT_RESIZE_THROTTLE_MS } from '@/common/components/ResizablePanel' 2 | import { selectIsRunning } from '@/features/controller/controllerSlice' 3 | 4 | import { useSelector } from './store' 5 | 6 | const ResizablePanel: typeof BaseResizablePanel = (props) => { 7 | const isRunning = useSelector(selectIsRunning) 8 | const throttleMs = DEFAULT_RESIZE_THROTTLE_MS * (isRunning ? 2 : 1) 9 | return 10 | } 11 | 12 | export default ResizablePanel 13 | -------------------------------------------------------------------------------- /src/app/hooks.ts: -------------------------------------------------------------------------------- 1 | import * as tracker from 'ackee-tracker' 2 | import { useEffect } from 'react' 3 | 4 | import { useSingleton } from '@/common/hooks' 5 | 6 | const DETAILED = true 7 | 8 | export const useAckee = () => { 9 | const instance = useSingleton(() => tracker.create('https://ackee.exuanbo.xyz/')) 10 | 11 | useEffect(() => { 12 | const attributes = tracker.attributes(DETAILED) 13 | 14 | const { pathname, origin } = window.location 15 | const url = new URL(pathname, origin) 16 | 17 | const { stop } = instance.record('bc75fd47-884f-4723-aaf6-3384103e0095', { 18 | ...attributes, 19 | siteLocation: url.href, 20 | }) 21 | return stop 22 | }, [instance]) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/store/enhancers/getStateWithSelector.ts: -------------------------------------------------------------------------------- 1 | import type { Selector, Store } from '@reduxjs/toolkit' 2 | 3 | import { injectStoreExtension } from './injectStoreExtension' 4 | 5 | interface GetStateWithSelector { 6 | (this: Store): State 7 | (this: Store, selector: Selector): Result 8 | } 9 | 10 | export const getStateWithSelector = injectStoreExtension<{ getState: GetStateWithSelector }>( 11 | (store: Store) => { 12 | const getState = function (selector?: Selector) { 13 | const state = store.getState() 14 | return selector ? selector(state) : state 15 | } 16 | return { getState } 17 | }, 18 | ) 19 | -------------------------------------------------------------------------------- /src/app/store/enhancers/injectStoreExtension.ts: -------------------------------------------------------------------------------- 1 | import type { Store, StoreEnhancer } from '@reduxjs/toolkit' 2 | 3 | export const injectStoreExtension = ( 4 | createExtension: (store: Store) => StoreExtension, 5 | ): StoreEnhancer => 6 | (next) => 7 | (...args) => { 8 | const store = next(...args) 9 | return { 10 | ...store, 11 | ...createExtension(store), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/store/enhancers/subscribeChange.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from '@reduxjs/toolkit' 2 | 3 | import { invariant } from '@/common/utils' 4 | 5 | import { injectStoreExtension } from './injectStoreExtension' 6 | 7 | type SubscribeChange = Store['subscribe'] 8 | 9 | export const subscribeChange = injectStoreExtension<{ subscribeChange: SubscribeChange }>( 10 | (store: Store) => { 11 | const NIL = Symbol('NIL') 12 | let snapshot: State | typeof NIL = NIL 13 | 14 | const dispatch: typeof store.dispatch = (action) => { 15 | invariant(snapshot === NIL) 16 | snapshot = store.getState() 17 | try { 18 | return store.dispatch(action) 19 | } 20 | finally { 21 | snapshot = NIL 22 | } 23 | } 24 | 25 | const subscribeChange: SubscribeChange = (listener) => 26 | store.subscribe(() => { 27 | invariant(snapshot !== NIL) 28 | if (snapshot !== store.getState()) { 29 | listener() 30 | } 31 | }) 32 | 33 | return { dispatch, subscribeChange } 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /src/app/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineSlices, configureStore } from '@reduxjs/toolkit' 2 | import { defineStore } from 'use-external-store' 3 | 4 | import { assemblerSlice } from '@/features/assembler/assemblerSlice' 5 | import { controllerSlice } from '@/features/controller/controllerSlice' 6 | import { cpuSlice } from '@/features/cpu/cpuSlice' 7 | import { editorSlice } from '@/features/editor/editorSlice' 8 | import { exceptionSlice } from '@/features/exception/exceptionSlice' 9 | import { ioSlice } from '@/features/io/ioSlice' 10 | import { memorySlice } from '@/features/memory/memorySlice' 11 | 12 | import { getStateWithSelector } from './enhancers/getStateWithSelector' 13 | import { subscribeChange } from './enhancers/subscribeChange' 14 | import { createActionObserver } from './observers/actionObserver' 15 | import { createStateObserver } from './observers/stateObserver' 16 | import { 17 | readStateFromPersistence, 18 | selectStateToPersist, 19 | writeStateToPersistence, 20 | } from './persistence' 21 | 22 | const rootReducer = combineSlices( 23 | editorSlice, 24 | assemblerSlice, 25 | controllerSlice, 26 | memorySlice, 27 | cpuSlice, 28 | ioSlice, 29 | exceptionSlice, 30 | ) 31 | 32 | export type RootState = ReturnType 33 | 34 | const stateObserver = createStateObserver() 35 | const actionObserver = createActionObserver() 36 | 37 | export const store = configureStore({ 38 | reducer: rootReducer, 39 | middleware: (getDefaultMiddleware) => { 40 | const defaultMiddleware = getDefaultMiddleware() 41 | return defaultMiddleware.concat(actionObserver.middleware, stateObserver.middleware) 42 | }, 43 | devTools: import.meta.env.DEV, 44 | preloadedState: readStateFromPersistence(), 45 | enhancers: (getDefaultEnhancers) => { 46 | const defaultEnhancers = getDefaultEnhancers({ autoBatch: false }) 47 | return defaultEnhancers 48 | .concat(actionObserver.enhancer, stateObserver.enhancer) 49 | .concat(getStateWithSelector) 50 | .concat(subscribeChange) 51 | }, 52 | }) 53 | 54 | store.onState(selectStateToPersist).subscribe(writeStateToPersistence) 55 | 56 | export const readonlyStore = defineStore({ 57 | getState: store.getState, 58 | subscribe: store.subscribeChange, 59 | }) 60 | 61 | export * from './selector' 62 | -------------------------------------------------------------------------------- /src/app/store/observers/actionObserver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Action, 3 | isAction, 4 | type Middleware, 5 | type PayloadAction, 6 | type PayloadActionCreator, 7 | type StoreEnhancer, 8 | } from '@reduxjs/toolkit' 9 | import { filter, map, type Observable, share, Subject } from 'rxjs' 10 | 11 | import { injectStoreExtension } from '../enhancers/injectStoreExtension' 12 | import { weakMemo } from './weakMemo' 13 | 14 | type ObserveAction = (actionCreator: PayloadActionCreator) => Observable 15 | 16 | interface ActionObserver { 17 | middleware: Middleware 18 | enhancer: StoreEnhancer<{ onAction: ObserveAction }> 19 | } 20 | 21 | const matchType = (actionCreator: PayloadActionCreator) => 22 | (action: Action): action is PayloadAction => 23 | action.type === actionCreator.type 24 | 25 | const getPayload = (action: PayloadAction): Payload => action.payload 26 | 27 | export const createActionObserver = (): ActionObserver => { 28 | const action$ = new Subject() 29 | 30 | const middleware: Middleware = () => (next) => (action) => { 31 | const result = next(action) 32 | if (isAction(action)) { 33 | action$.next(action) 34 | } 35 | return result 36 | } 37 | 38 | const onAction: ObserveAction = weakMemo((actionCreator) => 39 | action$.pipe( 40 | filter(matchType(actionCreator)), 41 | map(getPayload), 42 | share(), 43 | ), 44 | ) 45 | 46 | const enhancer = injectStoreExtension(() => ({ onAction })) 47 | 48 | return { middleware, enhancer } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/store/observers/stateObserver.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware, Selector, StoreEnhancer } from '@reduxjs/toolkit' 2 | import { distinctUntilChanged, map, type Observable, ReplaySubject, share, shareReplay } from 'rxjs' 3 | 4 | import { injectStoreExtension } from '../enhancers/injectStoreExtension' 5 | import { weakMemo } from './weakMemo' 6 | 7 | type ObserveState = (selector: Selector) => Observable 8 | 9 | interface StateObserver { 10 | middleware: Middleware<{}, State> 11 | enhancer: StoreEnhancer<{ onState: ObserveState }> 12 | } 13 | 14 | const BUFFER_SIZE = 1 // latest state only 15 | 16 | export const createStateObserver = (): StateObserver => { 17 | const state$ = new ReplaySubject(BUFFER_SIZE) 18 | 19 | const distinctState$ = state$.pipe( 20 | distinctUntilChanged(), 21 | shareReplay(BUFFER_SIZE), 22 | ) 23 | 24 | const middleware: Middleware<{}, State> = (api) => { 25 | const initialState = api.getState() 26 | state$.next(initialState) 27 | 28 | return (next) => (action) => { 29 | const result = next(action) 30 | state$.next(api.getState()) 31 | return result 32 | } 33 | } 34 | 35 | const onState: ObserveState = weakMemo((selector) => 36 | distinctState$.pipe( 37 | map(selector), 38 | distinctUntilChanged(), 39 | share(), 40 | ), 41 | ) 42 | 43 | const enhancer = injectStoreExtension(() => ({ onState })) 44 | 45 | return { middleware, enhancer } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/store/observers/weakMemo.ts: -------------------------------------------------------------------------------- 1 | export const weakMemo = (fn: (arg: Arg) => Result): typeof fn => { 2 | const cache = new WeakMap() 3 | return (arg) => { 4 | if (!cache.has(arg)) { 5 | cache.set(arg, fn(arg)) 6 | } 7 | return cache.get(arg)! 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/store/persistence/combinedProvider.ts: -------------------------------------------------------------------------------- 1 | import { createLocalStorageProvider } from './providers/localStorage' 2 | import { createQueryParamProvider } from './providers/queryParam' 3 | import type { PersistenceProvider, PersistenceProviderCreator, PersistenceValidator } from './types' 4 | 5 | const defaultProviderCreators = [createLocalStorageProvider, createQueryParamProvider] 6 | 7 | export const createCombinedProvider = ( 8 | combine: (a: State, b: State) => State, 9 | providerCreators: PersistenceProviderCreator[] = defaultProviderCreators, 10 | ) => 11 | (validate: PersistenceValidator, fallback: State): PersistenceProvider => { 12 | const providers = providerCreators.map((createProvider) => createProvider(validate, fallback)) 13 | return { 14 | read: () => providers.map((provider) => provider.read()).reduce(combine), 15 | write: (state) => providers.forEach((provider) => provider.write(state)), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/store/persistence/index.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import { ary, isPlainObject, merge, mergeSafe, type PlainObject } from '@/common/utils' 4 | import { controllerSlice } from '@/features/controller/controllerSlice' 5 | import { editorSlice } from '@/features/editor/editorSlice' 6 | 7 | import { createCombinedProvider } from './combinedProvider' 8 | 9 | const provider = createCombinedProvider(ary(merge, 2))(isPlainObject, {}) 10 | 11 | type PreloadedState = { 12 | [editorSlice.reducerPath]: ReturnType 13 | [controllerSlice.reducerPath]: ReturnType 14 | } 15 | 16 | export const readStateFromPersistence = (): PreloadedState => { 17 | const persistedState = provider.read() 18 | // in case of future changes to the state shape 19 | return mergeSafe( 20 | { 21 | [editorSlice.reducerPath]: editorSlice.getInitialState(), 22 | [controllerSlice.reducerPath]: controllerSlice.getInitialState(), 23 | }, 24 | persistedState, 25 | ) 26 | } 27 | 28 | export const selectStateToPersist = createSelector( 29 | editorSlice.selectors.selectToPersist, 30 | controllerSlice.selectors.selectToPersist, 31 | (editorState, controllerState) => ({ 32 | [editorSlice.reducerPath]: editorState, 33 | [controllerSlice.reducerPath]: controllerState, 34 | }), 35 | ) 36 | 37 | type StateToPersist = ReturnType 38 | 39 | export const writeStateToPersistence = (state: StateToPersist) => provider.write(state) 40 | -------------------------------------------------------------------------------- /src/app/store/persistence/providers/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { name } from '@/../package.json' 2 | 3 | import type { PersistenceProviderCreator } from '../types' 4 | 5 | const LOCAL_STORAGE_KEY = `persist:${name}` 6 | 7 | export const createLocalStorageProvider: PersistenceProviderCreator = (validate, fallbackState) => { 8 | return { 9 | read: () => { 10 | try { 11 | const serializedState = localStorage.getItem(LOCAL_STORAGE_KEY) 12 | if (serializedState !== null) { 13 | const state: unknown = JSON.parse(serializedState) 14 | if (validate(state)) { 15 | return state 16 | } 17 | } 18 | } 19 | catch { 20 | // ignore error 21 | } 22 | return fallbackState 23 | }, 24 | 25 | write: (state) => { 26 | try { 27 | const serializedState = JSON.stringify(state) 28 | localStorage.setItem(LOCAL_STORAGE_KEY, serializedState) 29 | } 30 | catch { 31 | // ignore write error 32 | } 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/store/persistence/providers/queryParam.ts: -------------------------------------------------------------------------------- 1 | import * as Base64 from 'js-base64' 2 | import * as Pako from 'pako' 3 | 4 | import type { PersistenceProviderCreator } from '../types' 5 | 6 | const QUERY_PARAMETER_NAME = 'shareable' 7 | 8 | const getShareUrl = (state: unknown) => { 9 | const url = new URL(window.location.href) 10 | const compressedData = Pako.deflate(JSON.stringify(state)) 11 | const encodedState = Base64.fromUint8Array(compressedData, /* urlsafe: */ true) 12 | url.searchParams.set(QUERY_PARAMETER_NAME, encodedState) 13 | return url 14 | } 15 | 16 | export const createQueryParamProvider: PersistenceProviderCreator = (validate, fallback) => { 17 | return { 18 | read: () => { 19 | const url = new URL(window.location.href) 20 | const encodedState = url.searchParams.get(QUERY_PARAMETER_NAME) 21 | if (encodedState !== null) { 22 | try { 23 | const compressedData = Base64.toUint8Array(encodedState) 24 | const decodedState = Pako.inflate(compressedData, { to: 'string' }) 25 | const state: unknown = JSON.parse(decodedState) 26 | if (validate(state)) { 27 | return state 28 | } 29 | } 30 | catch { 31 | // ignore error 32 | } 33 | } 34 | return fallback 35 | }, 36 | 37 | write: (state) => { 38 | const shareUrl = getShareUrl(state) 39 | window.history.replaceState({}, '', shareUrl) 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/store/persistence/types.ts: -------------------------------------------------------------------------------- 1 | export interface PersistenceProvider { 2 | read: () => State 3 | write: (state: State) => void 4 | } 5 | 6 | export type PersistenceValidator = (state: unknown) => state is State 7 | 8 | export type PersistenceProviderCreator = ( 9 | validate: PersistenceValidator, 10 | fallback: State, 11 | ) => PersistenceProvider 12 | -------------------------------------------------------------------------------- /src/app/store/selector/index.ts: -------------------------------------------------------------------------------- 1 | import type { Selector } from '@reduxjs/toolkit' 2 | import { useDebugValue } from 'react' 3 | import { useExternalStore } from 'use-external-store' 4 | 5 | import { readonlyStore, type RootState } from '..' 6 | import { useSyncExternalStoreWithSelector } from './useSyncExternalStoreWithSelector' 7 | 8 | export const useSyncSelector = ( 9 | selector: Selector, 10 | isEqual?: (a: Selection, b: Selection) => boolean, 11 | ): Selection => { 12 | const selection = useSyncExternalStoreWithSelector( 13 | readonlyStore.subscribe, 14 | readonlyStore.getState, 15 | selector, 16 | isEqual, 17 | ) 18 | useDebugValue(selection) 19 | return selection 20 | } 21 | 22 | export const useSelector = ( 23 | selector: Selector, 24 | isEqual?: (a: Selection, b: Selection) => boolean, 25 | ): Selection => { 26 | const selection = useExternalStore(readonlyStore, selector, isEqual) 27 | useDebugValue(selection) 28 | return selection 29 | } 30 | -------------------------------------------------------------------------------- /src/common/components/Anchor.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | 3 | const DEFAULT_CLASSNAME = 'text-blue-700 hover:underline' 4 | 5 | type Props = PropsWithChildren<{ 6 | href: string 7 | className?: string 8 | }> 9 | 10 | const Anchor: FC = ({ href, className = DEFAULT_CLASSNAME, children }) => { 11 | const isExternal = href.startsWith('http') 12 | return ( 13 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | export default Anchor 28 | -------------------------------------------------------------------------------- /src/common/components/CardHeader.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | 3 | import { classNames } from '../utils/classNames' 4 | 5 | type Props = PropsWithChildren<{ 6 | title: string 7 | onClick?: React.MouseEventHandler 8 | }> 9 | 10 | const CardHeader: FC = ({ title, onClick, children }) => { 11 | const isClickable = onClick !== undefined 12 | return ( 13 |
19 | {title} 20 | {children} 21 |
22 | ) 23 | } 24 | 25 | export default CardHeader 26 | -------------------------------------------------------------------------------- /src/common/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, type PropsWithChildren, useEffect, useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | 4 | import { type ClassItem, mergeClassNames } from '../utils' 5 | 6 | const modalContainer = document.getElementById('modal-root')! 7 | 8 | const modalClassName = 'fixed inset-0 flex items-center justify-center' 9 | 10 | type Props = PropsWithChildren<{ 11 | className?: ClassItem 12 | isOpen?: boolean 13 | }> 14 | 15 | const Modal: FC = ({ className, isOpen = false, children }) => { 16 | const [currentModal, setCurrentModal] = useState(null) 17 | const isReady = isOpen && !!currentModal 18 | 19 | useEffect(() => { 20 | if (!isOpen) { 21 | return 22 | } 23 | const modal = Object.assign(document.createElement('div'), { 24 | className: mergeClassNames(modalClassName, className), 25 | }) 26 | modalContainer.appendChild(modal) 27 | setCurrentModal(modal) 28 | return () => { 29 | modalContainer.removeChild(modal) 30 | setCurrentModal(null) 31 | } 32 | }, [isOpen, className]) 33 | 34 | return isReady && createPortal(children, currentModal) 35 | } 36 | 37 | export default Modal 38 | -------------------------------------------------------------------------------- /src/common/components/icons/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/arrow-1-svg/ 6 | const Arrow: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Arrow 13 | -------------------------------------------------------------------------------- /src/common/components/icons/ArrowDown.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/arrow-65-svg/ 6 | const ArrowDown: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default ArrowDown 13 | -------------------------------------------------------------------------------- /src/common/components/icons/ArrowUp.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/arrow-66-svg/ 6 | const ArrowUp: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default ArrowUp 13 | -------------------------------------------------------------------------------- /src/common/components/icons/CheckMark.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/check-mark-1-svg/ 6 | const CheckMark: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default CheckMark 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Close.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/x-mark-2-svg/ 6 | const Close: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Close 13 | -------------------------------------------------------------------------------- /src/common/components/icons/File.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/file-9-svg/ 6 | const File: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default File 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Forward.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/arrow-48-svg/ 6 | const Forward: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Forward 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Github.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://fontawesome.com/v5.15/icons/github-alt?style=brands 6 | const Github: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Github 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Help.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/help-1-svg/ 6 | const Help: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Help 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Play.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/media-control-48-svg/ 6 | const Play: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Play 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Share.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/share-11-svg/ 6 | const Share: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Share 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://css.gg/spinner 6 | const Spinner: FC = (props) => ( 7 | 8 | 15 | 16 | 17 | ) 18 | 19 | export default Spinner 20 | -------------------------------------------------------------------------------- /src/common/components/icons/Stop.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/media-control-50-svg/ 6 | const Stop: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Stop 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Undo.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/undo-4-svg/ 6 | const Undo: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Undo 13 | -------------------------------------------------------------------------------- /src/common/components/icons/View.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://iconmonstr.com/view-16-svg/ 6 | const View: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default View 13 | -------------------------------------------------------------------------------- /src/common/components/icons/Wrench.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { IconProps } from './types' 4 | 5 | // https://fontawesome.com/v5.15/icons/wrench?style=solid 6 | const Wrench: FC = (props) => ( 7 | 8 | 9 | 10 | ) 11 | 12 | export default Wrench 13 | -------------------------------------------------------------------------------- /src/common/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Arrow } from './Arrow' 2 | export { default as ArrowDown } from './ArrowDown' 3 | export { default as ArrowUp } from './ArrowUp' 4 | export { default as CheckMark } from './CheckMark' 5 | export { default as Close } from './Close' 6 | export { default as File } from './File' 7 | export { default as Forward } from './Forward' 8 | export { default as Github } from './Github' 9 | export { default as Help } from './Help' 10 | export { default as Play } from './Play' 11 | export { default as Share } from './Share' 12 | export { default as Spinner } from './Spinner' 13 | export { default as Stop } from './Stop' 14 | export { default as Undo } from './Undo' 15 | export { default as View } from './View' 16 | export { default as Wrench } from './Wrench' 17 | -------------------------------------------------------------------------------- /src/common/components/icons/types.ts: -------------------------------------------------------------------------------- 1 | export interface IconProps { 2 | [prop: string]: unknown 3 | } 4 | -------------------------------------------------------------------------------- /src/common/error.ts: -------------------------------------------------------------------------------- 1 | const IS_ERROR_OBJECT = '__IS_ERROR_OBJECT' 2 | 3 | export interface ErrorObject extends Error { 4 | [IS_ERROR_OBJECT]: true 5 | } 6 | 7 | export const errorToPlainObject = ({ name, message, stack }: Error): ErrorObject => { 8 | return { 9 | ...{ name, message, stack }, 10 | [IS_ERROR_OBJECT]: true, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/maybe.ts: -------------------------------------------------------------------------------- 1 | // Maybe monad implementation, API inspired by https://github.com/gigobyte/purify 2 | // Reference documentation https://gigobyte.github.io/purify/adts/Maybe 3 | // ISC licensed https://github.com/gigobyte/purify/blob/0840eb69b97617b09aca098f73948c61de563194/LICENSE 4 | 5 | export type Maybe = IMaybe 6 | 7 | export interface IMaybe { 8 | isJust: () => boolean 9 | isNothing: () => boolean 10 | map: (f: (value: T) => U) => IMaybe 11 | alt: (other: IMaybe) => IMaybe 12 | altLazy: (other: () => IMaybe) => IMaybe 13 | chain: (f: (value: T) => IMaybe) => IMaybe 14 | orDefault: (defaultValue: U) => T | U 15 | orDefaultLazy: (getDefaultValue: () => U) => T | U 16 | filter: (pred: (value: T) => boolean) => IMaybe 17 | extract: () => T | undefined 18 | extractNullable: () => T | null 19 | ifJust: (f: (value: T) => void) => this 20 | ifNothing: (f: () => void) => this 21 | } 22 | 23 | export const Just = (value: T): IMaybe => { 24 | const instance: IMaybe = { 25 | isJust: () => true, 26 | isNothing: () => false, 27 | map: (f) => Just(f(value)), 28 | alt: () => instance, 29 | altLazy: () => instance, 30 | chain: (f) => f(value), 31 | orDefault: () => value, 32 | orDefaultLazy: () => value, 33 | filter: (pred) => (pred(value) ? instance : Nothing), 34 | extract: () => value, 35 | extractNullable: () => value, 36 | ifJust: (f) => (f(value), instance), 37 | ifNothing: () => instance, 38 | } 39 | if (import.meta.env.DEV) { 40 | Object.freeze(instance) 41 | } 42 | return instance 43 | } 44 | 45 | export const Nothing: IMaybe = { 46 | isJust: () => false, 47 | isNothing: () => true, 48 | map: () => Nothing, 49 | alt: (other) => other, 50 | altLazy: (other) => other(), 51 | chain: () => Nothing, 52 | orDefault: (defaultValue) => defaultValue, 53 | orDefaultLazy: (getDefaultValue) => getDefaultValue(), 54 | filter: () => Nothing, 55 | extract: () => undefined, 56 | extractNullable: () => null, 57 | ifJust: () => Nothing, 58 | ifNothing: (f) => (f(), Nothing), 59 | } 60 | 61 | if (import.meta.env.DEV) { 62 | Object.freeze(Nothing) 63 | } 64 | 65 | type Nullish = null | undefined 66 | type MaybeFromNullable = (value: T | Nullish) => IMaybe 67 | 68 | export const fromNullable: MaybeFromNullable = (value) => (value != null ? Just(value) : Nothing) 69 | 70 | type Falsy = Nullish | false | 0 | 0n | '' 71 | type MaybeFromFalsy = (value: T | Falsy) => IMaybe 72 | 73 | export const fromFalsy: MaybeFromFalsy = (value) => (value ? Just(value) : Nothing) 74 | -------------------------------------------------------------------------------- /src/common/observe.ts: -------------------------------------------------------------------------------- 1 | import type { Observable, Observer, Subscription } from 'rxjs' 2 | 3 | export type Unsubscribe = Subscription['unsubscribe'] 4 | 5 | export const observe = ( 6 | observable: Observable, 7 | observerOrNext?: Partial> | ((value: T) => void) | null, 8 | ): Unsubscribe => { 9 | const subscription = observable.subscribe(observerOrNext) 10 | return () => subscription.unsubscribe() 11 | } 12 | -------------------------------------------------------------------------------- /src/common/utils/classNames.ts: -------------------------------------------------------------------------------- 1 | // Simplified fork of clsx 2 | // with limited support of input type. 3 | // https://github.com/lukeed/clsx/blob/74cefa60314506f93a4db565c59152c6c0a2295c/src/index.js 4 | // MIT Licensed https://github.com/lukeed/clsx/blob/74cefa60314506f93a4db565c59152c6c0a2295c/license 5 | 6 | import type { Nullable } from './types' 7 | 8 | export type ClassItem = Nullable> | false> 9 | 10 | export const classNames = (...items: ClassItem[]): string => { 11 | let className = '' 12 | const count = items.length 13 | for (let index = 0; index < count; index++) { 14 | const item = items[index] 15 | if (!item) { 16 | continue 17 | } 18 | if (typeof item === 'string') { 19 | className && (className += ' ') 20 | className += item 21 | } 22 | else { 23 | for (const key in item) { 24 | if (item[key]) { 25 | className && (className += ' ') 26 | className += key 27 | } 28 | } 29 | } 30 | } 31 | return className 32 | } 33 | 34 | const stringToRecord = (item: string) => { 35 | const record: Record = {} 36 | const keys = item.split(' ') 37 | const count = keys.length 38 | for (let index = 0; index < count; index++) { 39 | const key = keys[index] 40 | if (key) { 41 | record[key] = true 42 | } 43 | } 44 | return record 45 | } 46 | 47 | export const mergeClassNames = (target: ClassItem, source: ClassItem): string => { 48 | if (!target) { 49 | return classNames(source) 50 | } 51 | if (!source) { 52 | return classNames(target) 53 | } 54 | if (typeof target === 'string') { 55 | return mergeClassNames(stringToRecord(target), source) 56 | } 57 | if (typeof source === 'string') { 58 | return mergeClassNames(target, stringToRecord(source)) 59 | } 60 | return classNames({ ...target, ...source }) 61 | } 62 | -------------------------------------------------------------------------------- /src/common/utils/context.ts: -------------------------------------------------------------------------------- 1 | // Modifed from Sukka's vanilla context implementation 2 | // https://blog.skk.moe/post/context-in-javascript/ 3 | // CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh 4 | 5 | export type ApplyProvider = (callback: () => R) => R 6 | 7 | export interface ContextProvider { 8 | (props: { value: T, callback: () => R }): R 9 | (props: { value: T }): ApplyProvider 10 | } 11 | 12 | export interface ContextConsumer { 13 | (callback: (value: T) => R): R 14 | (): T 15 | } 16 | 17 | export interface Context { 18 | Provider: ContextProvider 19 | Consumer: ContextConsumer 20 | } 21 | 22 | const NO_VALUE_DEFAULT = Symbol('NO_VALUE_DEFAULT') 23 | type ContextValue = T | typeof NO_VALUE_DEFAULT 24 | 25 | export function createContext(defaultValue: ContextValue = NO_VALUE_DEFAULT): Context { 26 | let contextValue = defaultValue 27 | 28 | const Provider = ({ value, callback }: { value: T, callback?: () => R }) => { 29 | if (!callback) { 30 | return (fn: typeof callback) => Provider({ value, callback: fn }) 31 | } 32 | const currentValue = contextValue 33 | contextValue = value 34 | try { 35 | return callback() 36 | } 37 | finally { 38 | contextValue = currentValue 39 | } 40 | } 41 | 42 | const Consumer = (callback?: (value: T) => R) => { 43 | if (contextValue === NO_VALUE_DEFAULT) { 44 | throw new TypeError('Missing context: use within Provider or set default value.') 45 | } 46 | if (!callback) { 47 | return contextValue 48 | } 49 | return callback(contextValue) 50 | } 51 | 52 | return { 53 | Provider, 54 | Consumer, 55 | } 56 | } 57 | 58 | export function useContext(Context: Context): T { 59 | return Context.Consumer() 60 | } 61 | 62 | export interface ContextComposeProviderProps { 63 | contexts: ApplyProvider[] 64 | callback: () => R 65 | } 66 | 67 | export function ComposeProvider({ contexts, callback }: ContextComposeProviderProps): R { 68 | const applyProviders = contexts.reduceRight( 69 | (composed, current) => (fn) => current(() => composed(fn)), 70 | (fn) => fn(), 71 | ) 72 | return applyProviders(callback) 73 | } 74 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: remove this file 2 | 3 | export * from './classNames' 4 | export * from './common' 5 | export * from './invariant' 6 | export * from './merge' 7 | export * from './mergeSafe' 8 | export * from './types' 9 | -------------------------------------------------------------------------------- /src/common/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | // Simplified fork of tiny-invariant 2 | // https://github.com/alexreardon/tiny-invariant/blob/619da0f9119558cd57aeff1ba5d022cad74f9bc7/src/tiny-invariant.ts 3 | // MIT Licensed https://github.com/alexreardon/tiny-invariant/blob/619da0f9119558cd57aeff1ba5d022cad74f9bc7/LICENSE 4 | 5 | const PREFIX = 'Invariant failed' 6 | 7 | // istanbul ignore next 8 | export function invariant(condition: unknown, message = ''): asserts condition { 9 | if (condition) { 10 | return 11 | } 12 | if (process.env.NODE_ENV === 'production') { 13 | throw new Error(PREFIX) 14 | } 15 | throw new Error(PREFIX + `${message && `: ${message}`}`) 16 | } 17 | -------------------------------------------------------------------------------- /src/common/utils/merge.ts: -------------------------------------------------------------------------------- 1 | // Simplified fork of merge-anything 2 | // https://github.com/mesqueeb/merge-anything/blob/e492bfc05b2b333a5c6316e0dbc8953752eafe07/src/merge.ts 3 | // MIT Licensed https://github.com/mesqueeb/merge-anything/blob/e492bfc05b2b333a5c6316e0dbc8953752eafe07/LICENSE 4 | 5 | import type { O } from 'ts-toolbelt' 6 | 7 | import { isPlainObject, type PlainObject } from './common' 8 | 9 | const mergeRec = (target: unknown, source: PlainObject): PlainObject => { 10 | const result: PlainObject = {} 11 | const sourcePropertyNames = Object.getOwnPropertyNames(source) 12 | const sourcePropertySymbols = Object.getOwnPropertySymbols(source) 13 | const isTargetPlainObject = isPlainObject(target) 14 | if (isTargetPlainObject) { 15 | const targetPropertyNames = Object.getOwnPropertyNames(target) 16 | const targetPropertySymbols = Object.getOwnPropertySymbols(target) 17 | const assignTargetProperty = (key: string | symbol): void => { 18 | const isKeySymbol = typeof key === 'symbol' 19 | if ( 20 | (!isKeySymbol && !sourcePropertyNames.includes(key)) 21 | || (isKeySymbol && !sourcePropertySymbols.includes(key)) 22 | ) { 23 | Object.defineProperty(result, key, Object.getOwnPropertyDescriptor(target, key)!) 24 | } 25 | } 26 | targetPropertyNames.forEach(assignTargetProperty) 27 | targetPropertySymbols.forEach(assignTargetProperty) 28 | } 29 | const assignSourceProperty = (key: string | symbol): void => { 30 | const sourcePropertyValue = source[key] 31 | const shouldMerge = isTargetPlainObject && isPlainObject(sourcePropertyValue) 32 | if (shouldMerge) { 33 | const targetPropertyValue: unknown = target[key] 34 | Object.defineProperty(result, key, { 35 | ...Object.getOwnPropertyDescriptor(source, key), 36 | value: mergeRec(targetPropertyValue, sourcePropertyValue), 37 | }) 38 | } 39 | else { 40 | Object.defineProperty(result, key, Object.getOwnPropertyDescriptor(source, key)!) 41 | } 42 | } 43 | sourcePropertyNames.forEach(assignSourceProperty) 44 | sourcePropertySymbols.forEach(assignSourceProperty) 45 | return result 46 | } 47 | 48 | // https://stackoverflow.com/a/57683652/13346012 49 | type ExpandDeep = 50 | T extends Record 51 | ? { [K in keyof T]: ExpandDeep } 52 | : T extends Array 53 | ? Array> 54 | : T 55 | 56 | export const merge = ( 57 | target: Target, 58 | ...sources: Sources 59 | ) => sources.reduce(mergeRec, target) as ExpandDeep> 60 | -------------------------------------------------------------------------------- /src/common/utils/mergeSafe.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject, isSameType, type PlainObject } from './common' 2 | 3 | const mergeSafeRec = (target: Target, source: unknown): Target => { 4 | if (!isSameType(target, source)) { 5 | return target 6 | } 7 | // source is guaranteed to be a plain object here but TypeScript doesn't know that 8 | if (!isPlainObject(target) || !isPlainObject(source)) { 9 | return source 10 | } 11 | const result: PlainObject = {} 12 | const targetPropertyNames = Object.getOwnPropertyNames(target) 13 | const targetPropertySymbols = Object.getOwnPropertySymbols(target) 14 | const sourcePropertyNames = Object.getOwnPropertyNames(source) 15 | const sourcePropertySymbols = Object.getOwnPropertySymbols(source) 16 | const assignTargetProperty = (key: string | symbol): void => { 17 | const isKeySymbol = typeof key === 'symbol' 18 | if ( 19 | (!isKeySymbol && !sourcePropertyNames.includes(key)) 20 | || (isKeySymbol && !sourcePropertySymbols.includes(key)) 21 | ) { 22 | Object.defineProperty(result, key, Object.getOwnPropertyDescriptor(target, key)!) 23 | } 24 | } 25 | targetPropertyNames.forEach(assignTargetProperty) 26 | targetPropertySymbols.forEach(assignTargetProperty) 27 | const assignSourceProperty = (key: string | symbol): void => { 28 | const isKeySymbol = typeof key === 'symbol' 29 | if ( 30 | (!isKeySymbol && targetPropertyNames.includes(key)) 31 | || (isKeySymbol && targetPropertySymbols.includes(key)) 32 | ) { 33 | const targetPropertyValue = target[key] 34 | const sourcePropertyValue: unknown = source[key] 35 | Object.defineProperty(result, key, { 36 | ...Object.getOwnPropertyDescriptor(target, key), 37 | value: mergeSafeRec(targetPropertyValue, sourcePropertyValue), 38 | }) 39 | } 40 | } 41 | sourcePropertyNames.forEach(assignSourceProperty) 42 | sourcePropertySymbols.forEach(assignSourceProperty) 43 | return result 44 | } 45 | 46 | export const mergeSafe = ( 47 | target: Target, 48 | ...sources: Sources 49 | ): Target => sources.reduce(mergeSafeRec, target) 50 | -------------------------------------------------------------------------------- /src/common/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { A, Test } from 'ts-toolbelt' 2 | 3 | export type Nullable = T | null | undefined 4 | 5 | type UnionToFunctionWithUnionAsArg = (arg: Union) => void 6 | 7 | type UnionToFunctionIntersectionWithUnionMemberAsArg = ( 8 | Union extends never ? never : (arg: UnionToFunctionWithUnionAsArg) => void 9 | ) extends (arg: infer ArgAsFunctionIntersection) => void 10 | ? ArgAsFunctionIntersection 11 | : never 12 | 13 | // Modified from a comment in the issue 14 | // "Type manipulations: union to tuple #13298" 15 | // https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381 16 | export type UnionToTuple = 17 | UnionToFunctionIntersectionWithUnionMemberAsArg extends ( 18 | arg: infer ArgAsLastUnionMember, 19 | ) => void 20 | ? [...UnionToTuple>, ArgAsLastUnionMember] 21 | : [] 22 | 23 | export declare function checkType(): A.Equals< 24 | A.Equals, 25 | Outcome 26 | > 27 | 28 | export declare function checkTypes(outcomes: Test.Pass[]): void 29 | -------------------------------------------------------------------------------- /src/core/README.md: -------------------------------------------------------------------------------- 1 | # Core 2 | 3 | ## Assembler 4 | 5 | ## Memory 6 | 7 | ## CPU 8 | 9 | ## Bus 10 | -------------------------------------------------------------------------------- /src/core/assembler/README.md: -------------------------------------------------------------------------------- 1 | # Assembler 2 | -------------------------------------------------------------------------------- /src/core/assembler/asm.ebnf: -------------------------------------------------------------------------------- 1 | program = statement_list, comment_list ; 2 | 3 | statement_list = { statement } ; 4 | statement = [ label ], ( instruction | directive ) ; 5 | 6 | comment_list = { comment } ; 7 | comment = ';', { character - newline }, newline ; 8 | 9 | label = identifier, ':' ; 10 | 11 | instruction = arithmetic | jump | move | compare | stack | procedure | interrupt | input_output | control ; 12 | 13 | arithmetic = unary_arithmetic | binary_arithmetic ; 14 | unary_arithmetic = ('INC' | 'DEC' | 'NOT' | 'ROL' | 'ROR' | 'SHL' | 'SHR'), register ; 15 | binary_arithmetic = ('ADD' | 'SUB' | 'MUL' | 'DIV' | 'MOD' | 'AND' | 'OR' | 'XOR'), register, ',', (register | immediate) ; 16 | 17 | jump = ('JMP' | 'JZ' | 'JNZ' | 'JS' | 'JNS' | 'JO' | 'JNO'), identifier ; 18 | 19 | move = 'MOV', (register | memory_operand), ',', (register | immediate | memory_operand) ; 20 | 21 | compare = 'CMP', register, ',', (register | immediate | memory_operand) ; 22 | 23 | stack = general_stack | flag_stack ; 24 | general_stack = ('PUSH' | 'POP'), register ; 25 | flag_stack = 'PUSHF' | 'POPF' ; 26 | 27 | procedure = call_procedure | return_procedure ; 28 | call_procedure = 'CALL', immediate ; 29 | return_procedure = 'RET' ; 30 | 31 | interrupt = trap_interrupt | return_interrupt ; 32 | trap_interrupt = 'INT', immediate ; 33 | return_interrupt = 'IRET' ; 34 | 35 | input_output = ('IN' | 'OUT'), immediate ; 36 | 37 | control = 'HALT' | 'STI' | 'CLI' | 'CLO' | 'NOP' ; 38 | 39 | directive = end_directive | org_directive | db_directive ; 40 | end_directive = 'END' ; 41 | org_directive = 'ORG', immediate ; 42 | db_directive = 'DB', (string_literal | immediate) ; 43 | 44 | memory_operand = '[', (register | immediate), ']' ; 45 | 46 | register = 'AL' | 'BL' | 'CL' | 'DL' ; 47 | 48 | identifier = (letter | '_'), { letter | digit | '_' } ; 49 | 50 | immediate = digit, { digit | 'A'..'F' } ; 51 | 52 | string_literal = '"', { character - '"' }, '"' ; 53 | 54 | letter = 'A'..'Z' | 'a'..'z' ; 55 | digit = '0'..'9' ; 56 | character = ? any ASCII character ? ; 57 | 58 | newline = ? ASCII newline character ? ; 59 | -------------------------------------------------------------------------------- /src/core/assembler/assembler.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { examples } from '@/features/editor/examples' 4 | 5 | import { Assembler } from './assembler' 6 | 7 | describe('Assembler', () => { 8 | examples.forEach(({ title, content }) => { 9 | it(`should assemble example ${title}`, () => { 10 | const assembler = new Assembler() 11 | const unit = assembler.assemble(content) 12 | expect(unit.ast).not.toBeNull() 13 | expect(unit.chunks.length).toBeGreaterThan(0) 14 | expect(unit.warnings).toHaveLength(0) 15 | expect(unit.errors).toHaveLength(0) 16 | }) 17 | }) 18 | 19 | it('should collect parser errors', () => { 20 | const assembler = new Assembler() 21 | const unit = assembler.assemble('inc al') 22 | expect(unit.errors).toHaveLength(1) 23 | expect(unit.errors[0]).toMatchInlineSnapshot( 24 | `[ParserError: Expected directive 'END']`, 25 | ) 26 | }) 27 | 28 | it('should collect parser warnings', () => { 29 | const assembler = new Assembler() 30 | const unit = assembler.assemble('label: end') 31 | expect(unit.warnings).toHaveLength(1) 32 | expect(unit.warnings[0]).toMatchInlineSnapshot( 33 | `[ParserWarning: Unreferenced label 'LABEL']`, 34 | ) 35 | }) 36 | 37 | it('should validate jump distances', () => { 38 | const assembler = new Assembler() 39 | const unit = assembler.assemble('jmp label org 81 label: end') 40 | expect(unit.errors).toHaveLength(1) 41 | expect(unit.errors[0]).toMatchInlineSnapshot( 42 | `[AssemblerError: Jump offset 129 out of range (-128 to -1 backward, 0 to 127 forward)]`, 43 | ) 44 | }) 45 | 46 | it('should validate immediate values', () => { 47 | const assembler = new Assembler() 48 | const unit = assembler.assemble('add al, 100 end') 49 | expect(unit.errors).toHaveLength(1) 50 | expect(unit.errors[0]).toMatchInlineSnapshot( 51 | `[AssemblerError: Immediate value 256 exceeds maximum of 255]`, 52 | ) 53 | }) 54 | 55 | it('should validate string literals', () => { 56 | const assembler = new Assembler() 57 | const unit = assembler.assemble('db "你好世界" end') 58 | expect(unit.errors).toHaveLength(1) 59 | expect(unit.errors[0]).toMatchInlineSnapshot( 60 | `[AssemblerError: Character '你' has UTF-16 code 20320 exceeds maximum of 255]`, 61 | ) 62 | }) 63 | 64 | it('should validate org address', () => { 65 | const assembler = new Assembler() 66 | const unit = assembler.assemble('org 100 end') 67 | expect(unit.errors).toHaveLength(1) 68 | expect(unit.errors[0]).toMatchInlineSnapshot( 69 | `[AssemblerError: Memory address exceeds maximum of 255]`, 70 | ) 71 | }) 72 | 73 | it('should throw an error when memory overflows', () => { 74 | const assembler = new Assembler() 75 | const unit = assembler.assemble('org ff inc al end') 76 | expect(unit.errors).toHaveLength(1) 77 | expect(unit.errors[0]).toMatchInlineSnapshot( 78 | `[AssemblerError: Memory address exceeds maximum of 255]`, 79 | ) 80 | }) 81 | 82 | it('should merge errors', () => { 83 | const assembler = new Assembler() 84 | const unit = assembler.assemble('org ff inc al inc bl inc cl end') 85 | expect(unit.errors).toHaveLength(1) 86 | expect(unit.errors[0]).toMatchInlineSnapshot( 87 | `[AssemblerError: Memory address exceeds maximum of 255]`, 88 | ) 89 | expect(unit.errors[0].loc).toHaveLength(3) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/core/assembler/assembler.state.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from '@/common/utils/context' 2 | 3 | import { getSize } from './assembler.utils' 4 | import type { AssemblyNode } from './assemblyunit' 5 | import type * as AST from './ast' 6 | import { AssemblerError, ErrorCode } from './errors' 7 | 8 | const MAX_MEMORY_ADDRESS = 0xff 9 | 10 | export interface AssemblerState { 11 | get address(): number 12 | setAddress(node: AST.Immediate): void 13 | advanceAddress(node: AssemblyNode): void 14 | } 15 | 16 | export function createAssemblerState(initialAddress = 0): AssemblerState { 17 | let address = initialAddress 18 | return { 19 | get address() { 20 | return address 21 | }, 22 | setAddress({ children: [value], loc }) { 23 | if (value > MAX_MEMORY_ADDRESS) { 24 | throw new AssemblerError(ErrorCode.MemoryOverflow, loc) 25 | } 26 | address = value 27 | }, 28 | advanceAddress(node) { 29 | const nextAddress = address + getSize(node) 30 | if (nextAddress > MAX_MEMORY_ADDRESS) { 31 | throw new AssemblerError(ErrorCode.MemoryOverflow, node.loc) 32 | } 33 | address = nextAddress 34 | }, 35 | } 36 | } 37 | 38 | export const AssemblerState = createContext() 39 | 40 | export function useAssemblerState(): AssemblerState { 41 | return useContext(AssemblerState) 42 | } 43 | -------------------------------------------------------------------------------- /src/core/assembler/assembler.utils.ts: -------------------------------------------------------------------------------- 1 | import type { AssemblyNode, AssemblyNodeValue } from './assemblyunit' 2 | import * as AST from './ast' 3 | 4 | export type WithIdentifier = 5 | Node extends { children: infer Values extends unknown[] } 6 | ? HasIdentifier extends true 7 | ? Node 8 | : never 9 | : never 10 | 11 | type HasIdentifier = 12 | Values extends [infer First, ...infer Rest] 13 | ? First extends AST.Identifier 14 | ? true 15 | : First extends { children: infer FirstValues extends unknown[] } 16 | ? HasIdentifier extends true 17 | ? true 18 | : HasIdentifier 19 | : HasIdentifier 20 | : false 21 | 22 | const some = Array.prototype.some 23 | 24 | export function hasIdentifier(node: Node): node is WithIdentifier { 25 | return some.call(node.children, aux) 26 | 27 | function aux(value: AssemblyNodeValue) { 28 | return Object.hasOwn(value, 'type') 29 | && ( 30 | (value.type === AST.NodeType.Identifier) 31 | || some.call(value.children, aux) 32 | ) 33 | } 34 | } 35 | 36 | const reduce = Array.prototype.reduce 37 | 38 | const sizeCache = new WeakMap() 39 | 40 | export function getSize(node: AssemblyNode): number { 41 | const cached = sizeCache.get(node) 42 | if (cached) { 43 | return cached 44 | } 45 | const size = aux(0, node) 46 | return (sizeCache.set(node, size), size) 47 | 48 | function aux(acc: number, cur: AssemblyNodeValue) { 49 | return !Object.hasOwn(cur, 'children') 50 | ? (acc + 1) 51 | : reduce.call(cur.children, aux, acc) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/core/assembler/assemblyunit.ts: -------------------------------------------------------------------------------- 1 | import type * as AST from './ast' 2 | import { type AssemblyError, mergeErrors } from './errors' 3 | import type { LabelMap } from './parser.context' 4 | 5 | export interface AssemblyUnit { 6 | ast: AST.Program | null 7 | labels: LabelMap 8 | chunks: CodeChunk[] 9 | errors: AssemblyError[] 10 | warnings: AssemblyError[] 11 | } 12 | 13 | export interface CodeChunk { 14 | offset: number 15 | buffer: Uint8Array 16 | node: AssemblyNode 17 | } 18 | 19 | export interface AssemblyNode extends AST.Node { 20 | children: ArrayLike 21 | } 22 | 23 | export type AssemblyNodeValue = 24 | | AssemblyNode 25 | | ( 26 | | AST.Mnemonic 27 | | AST.OperandValue 28 | | AST.StringLiteral 29 | ) 30 | 31 | const nil: LabelMap = new Map() 32 | 33 | export function initUnit(): AssemblyUnit { 34 | return { 35 | ast: null, 36 | labels: nil, 37 | chunks: [], 38 | errors: [], 39 | warnings: [], 40 | } 41 | } 42 | 43 | export function finalize(unit: AssemblyUnit, updates: Partial): AssemblyUnit { 44 | const updated: AssemblyUnit = { 45 | ...unit, 46 | ...updates, 47 | } 48 | mergeErrors(updated.warnings) 49 | mergeErrors(updated.errors) 50 | return updated 51 | } 52 | -------------------------------------------------------------------------------- /src/core/assembler/instrset.utils.ts: -------------------------------------------------------------------------------- 1 | import type * as AST from './ast' 2 | import * as InstrSet from './instrset' 3 | 4 | export function resolveOpcode(mnemonic: AST.Mnemonic, operands: AST.Operand[]): number { 5 | for (const [opcode, patterns] of InstrSet.patterns[mnemonic]) { 6 | if (matchs(operands, patterns)) { 7 | return opcode 8 | } 9 | } 10 | throw new Error(`Cannot resolve opcode for instruction '${mnemonic}'`) 11 | } 12 | 13 | function match(operand: AST.Operand, pattern: InstrSet.OperandPattern): boolean { 14 | return (typeof pattern !== 'object') 15 | ? (operand.type === pattern) 16 | : matchs(operand.children, pattern.children) 17 | } 18 | 19 | function matchs(values: (AST.Operand | AST.OperandValue)[], patterns: InstrSet.OperandPattern[]) { 20 | return (values.length === patterns.length) 21 | && values.every((value, i) => 22 | Object.hasOwn(value, 'type') && match(value, patterns[i])) 23 | } 24 | -------------------------------------------------------------------------------- /src/core/assembler/parser.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from '@/common/utils/context' 2 | 3 | import type * as AST from './ast' 4 | import { ErrorCode, type ParserDiagnostic, ParserError, ParserWarning } from './errors' 5 | 6 | export interface LabelInfo { 7 | address: number 8 | refs: AST.SourceLocation[] 9 | loc: AST.SourceLocation | null 10 | } 11 | 12 | export type LabelMap = ReadonlyMap 13 | 14 | export interface ParserContext { 15 | get labels(): LabelMap 16 | refLabel(node: AST.Identifier): void 17 | addLabel(node: AST.Identifier): void 18 | checkLabels(): void 19 | 20 | catchError(callback: (() => void)): void 21 | flushErrors(): ParserDiagnostic[] 22 | } 23 | 24 | export function createParserContext(): ParserContext { 25 | const labelMap = new Map() 26 | const errors: ParserDiagnostic[] = [] 27 | 28 | return { 29 | get labels() { 30 | return labelMap 31 | }, 32 | refLabel({ children: [name], loc }) { 33 | const label = labelMap.get(name) 34 | if (label) { 35 | label.refs.push(loc) 36 | } 37 | else { 38 | labelMap.set(name, { address: NaN, refs: [loc], loc: null }) 39 | } 40 | }, 41 | addLabel({ children: [name], loc }) { 42 | const label = labelMap.get(name) 43 | if (label) { 44 | if (label.loc) { 45 | errors.push(new ParserError(ErrorCode.DuplicateLabel, loc, { name })) 46 | } 47 | label.loc = loc 48 | } 49 | else { 50 | labelMap.set(name, { address: NaN, refs: [], loc }) 51 | } 52 | }, 53 | checkLabels() { 54 | labelMap.forEach((label, name) => { 55 | if (label.loc) { 56 | if (!label.refs.length) { 57 | errors.push(new ParserWarning(ErrorCode.UnreferencedLabel, label.loc, { name })) 58 | } 59 | } 60 | else { 61 | errors.push(new ParserError(ErrorCode.UndefinedLabel, label.refs, { name })) 62 | } 63 | }) 64 | }, 65 | catchError(callback) { 66 | try { 67 | callback() 68 | } 69 | catch (error) { 70 | if (error instanceof ParserError) { 71 | errors.push(error) 72 | } 73 | else throw error 74 | } 75 | }, 76 | flushErrors() { 77 | return errors.splice(0, errors.length) 78 | }, 79 | } 80 | } 81 | 82 | export const ParserContext = createContext() 83 | 84 | export function useParserContext(): ParserContext { 85 | return useContext(ParserContext) 86 | } 87 | -------------------------------------------------------------------------------- /src/core/assembler/parser.utils.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from '@/common/utils/invariant' 2 | 3 | import * as AST from './ast' 4 | import { ErrorCode, ParserError } from './errors' 5 | import { useTokenStream } from './token.stream' 6 | 7 | export type ParserFn = (() => Node) 8 | 9 | const typeRegistry = new Map, AST.NodeType>() 10 | 11 | function getType(fn: ParserFn): Node['type'] { 12 | const type = typeRegistry.get(fn) 13 | invariant(type, `Parser function '${fn.name}' is not registered`) 14 | return type 15 | } 16 | export function registerType(fn: ParserFn, type: Node['type']) { 17 | typeRegistry.set(fn, type) 18 | return fn 19 | } 20 | 21 | const GUARD_FAILED: unknown = Symbol('GUARD_FAILED') 22 | 23 | export function guard(condition: unknown): asserts condition { 24 | if (!condition) { 25 | throw GUARD_FAILED 26 | } 27 | } 28 | 29 | export function tryParsers>(fns: Fn[]): ReturnType { 30 | const stream = useTokenStream() 31 | for (const fn of fns) { 32 | const restore = stream.snapshot() 33 | try { 34 | return fn() 35 | } 36 | catch (error) { 37 | if (error === GUARD_FAILED) { 38 | restore() 39 | } 40 | else throw error 41 | } 42 | } 43 | const token = stream.peek() 44 | const expected = joinNames(fns.map(getType)) 45 | throw new ParserError(ErrorCode.UnexpectedToken, token.loc, { expected }) 46 | } 47 | 48 | function joinNames(types: AST.NodeType[]): string { 49 | invariant(types.length) 50 | const names = types.map(AST.getNodeName) 51 | const formatter = new Intl.ListFormat('en', { type: 'disjunction' }) 52 | return formatter.format(names) 53 | } 54 | 55 | export function expectType(node: AST.Node, type: AST.NodeType): void { 56 | if (node.type !== type) { 57 | const expected = AST.getNodeName(type) 58 | throw new ParserError(ErrorCode.UnexpectedToken, node.loc, { expected }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/core/assembler/token.stream.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from '@/common/utils/context' 2 | import { invariant } from '@/common/utils/invariant' 3 | 4 | import { ErrorCode, ParserError } from './errors' 5 | import { getTokenName, type Token, TokenType } from './token' 6 | 7 | export type TokenHandler = ((token: Token) => void) 8 | 9 | export type RestoreSnapshot = (() => void) 10 | 11 | export interface TokenStream { 12 | hasMore(): boolean 13 | expect(type: TokenType): Token 14 | peek(onToken?: TokenHandler): Token 15 | next(onToken?: TokenHandler): Token 16 | snapshot(): RestoreSnapshot 17 | } 18 | 19 | export function createTokenStream(iter: Iterator): TokenStream { 20 | const tokens: Token[] = [] 21 | let position = 0 22 | 23 | const stream: TokenStream = { 24 | peek(onToken) { 25 | invariant(position <= tokens.length) 26 | if (position === tokens.length) { 27 | const { value } = iter.next() 28 | invariant(value) 29 | tokens.push(value) 30 | } 31 | const token = tokens[position] 32 | return (onToken?.(token), token) 33 | }, 34 | hasMore() { 35 | const token = stream.peek() 36 | return (token.type !== TokenType.EOI) 37 | }, 38 | next(onToken) { 39 | if (!stream.hasMore()) { 40 | const end = tokens[position] 41 | throw new ParserError(ErrorCode.UnexpectedEndOfInput, end.loc) 42 | } 43 | const token = tokens[position++] 44 | return (onToken?.(token), token) 45 | }, 46 | expect(type) { 47 | const token = stream.peek() 48 | if (token.type !== type) { 49 | const expected = getTokenName(type) 50 | throw new ParserError(ErrorCode.UnexpectedToken, token.loc, { expected }) 51 | } 52 | return stream.next() 53 | }, 54 | snapshot() { 55 | const positionSnapshot = position 56 | return () => (position = positionSnapshot) 57 | }, 58 | } 59 | 60 | return stream 61 | } 62 | 63 | export const TokenStream = createContext() 64 | 65 | export function useTokenStream(): TokenStream { 66 | return useContext(TokenStream) 67 | } 68 | -------------------------------------------------------------------------------- /src/core/assembler/token.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from '@/common/utils/invariant' 2 | 3 | import type * as AST from './ast' 4 | 5 | export const enum TokenType { 6 | Colon, 7 | Comma, 8 | LeftSquare, 9 | RightSquare, 10 | Number, 11 | String, 12 | Identifier, 13 | LabelIdentifier, 14 | Comment, 15 | EOI, 16 | } 17 | 18 | const tokennames: Partial> = { 19 | [TokenType.Comma]: 'comma', 20 | [TokenType.RightSquare]: 'closing square bracket', 21 | } 22 | 23 | export function getTokenName(type: TokenType): string { 24 | const name = tokennames[type] 25 | invariant(name, `Token name of type '${type}' is undefined`) 26 | return name 27 | } 28 | 29 | export interface Token { 30 | type: TokenType 31 | value: string 32 | loc: AST.SourceLocation 33 | } 34 | -------------------------------------------------------------------------------- /src/core/assembler/utils.ts: -------------------------------------------------------------------------------- 1 | export function parseHexNumber(text: string): number { 2 | // TODO: explain in comment 3 | return Number('0x' + text) 4 | } 5 | 6 | const escapeChars: Record = { 7 | '0': '\0', 8 | 't': '\t', 9 | 'n': '\n', 10 | 'r': '\r', 11 | '"': '"', 12 | '\\': '\\', 13 | } 14 | 15 | export function parseString(text: string): string { 16 | let result = '' 17 | let i = 1 18 | const len = text.length - 1 19 | 20 | while (i < len) { 21 | const char = text[i] 22 | if (char === '\\') { 23 | const nextChar = text[++i] 24 | result += (escapeChars[nextChar] || nextChar) 25 | } 26 | else { 27 | result += char 28 | } 29 | i++ 30 | } 31 | 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /src/core/bus/README.md: -------------------------------------------------------------------------------- 1 | # Bus 2 | -------------------------------------------------------------------------------- /src/core/bus/bus.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, filter, type Observable, share } from 'rxjs' 2 | 3 | export type Signal = 0b0 | 0b1 4 | 5 | export interface ControlLines { 6 | RD: Signal 7 | WR: Signal 8 | MREQ: Signal 9 | IORQ: Signal 10 | CLK: Signal 11 | WAIT: Signal 12 | IRQ: Signal 13 | HALT: Signal 14 | } 15 | 16 | const initialControlLines: ControlLines = { 17 | RD: 0b0, 18 | WR: 0b0, 19 | MREQ: 0b0, 20 | IORQ: 0b0, 21 | CLK: 0b0, 22 | WAIT: 0b0, 23 | IRQ: 0b0, 24 | HALT: 0b0, 25 | } 26 | 27 | export class Bus { 28 | readonly data$ = new BehaviorSubject(0x00) 29 | readonly address$ = new BehaviorSubject(0x00) 30 | readonly control$ = new BehaviorSubject(initialControlLines) 31 | 32 | readonly clockRise$: Observable = this.control$.pipe( 33 | filter((control, index) => (index && control.CLK)), 34 | share(), 35 | ) 36 | 37 | setControl(lines: Partial): void { 38 | const control = this.control$.getValue() 39 | this.control$.next(Object.assign(control, lines)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/clock/README.md: -------------------------------------------------------------------------------- 1 | # Clock 2 | -------------------------------------------------------------------------------- /src/core/clock/clock.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'di-wise' 2 | 3 | import { Bus } from '../bus/bus' 4 | 5 | export class Clock { 6 | private bus = inject(Bus) 7 | 8 | tick = (): void => { 9 | this.bus.setControl({ CLK: 0b1 }) 10 | this.bus.setControl({ CLK: 0b0 }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/core/controller/README.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | -------------------------------------------------------------------------------- /src/core/controller/controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { createContainer, Scope } from 'di-wise' 2 | import { firstValueFrom } from 'rxjs' 3 | import { describe, expect, it } from 'vitest' 4 | 5 | import { Memory } from '../memory/memory' 6 | import { Controller } from './controller' 7 | 8 | describe('Controller', () => { 9 | it('should step', async () => { 10 | const container = createContainer({ 11 | defaultScope: Scope.Container, 12 | autoRegister: true, 13 | }) 14 | 15 | const memory = container.resolve(Memory) 16 | memory.load(new Uint8Array([0x01, 0x02]), 0x00) 17 | 18 | const controller = container.resolve(Controller) 19 | await firstValueFrom(controller.step()) 20 | expect(memory.getData()[0x02]).toBe(0x03) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/core/controller/controller.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'di-wise' 2 | import { Observable, type Subscription } from 'rxjs' 3 | 4 | import { Bus } from '../bus/bus' 5 | import { Clock } from '../clock/clock' 6 | import { Cpu } from '../cpu/cpu' 7 | import { Memory } from '../memory/memory' 8 | 9 | export class Controller { 10 | private bus = inject(Bus) 11 | private cpu = inject(Cpu) 12 | private clock = inject(Clock) 13 | private memory = inject(Memory) 14 | 15 | step = (): Observable => { 16 | return new Observable((subscriber) => { 17 | const step = this.cpu.step() 18 | let subscription: Subscription | undefined 19 | 20 | const handleResult = (result: ReturnType) => { 21 | if (result.done) { 22 | subscriber.next() 23 | subscriber.complete() 24 | return 25 | } 26 | queueMicrotask(this.clock.tick) 27 | subscription?.unsubscribe() 28 | subscription = result.value.subscribe((signals) => { 29 | handleResult(step.next(signals)) 30 | }) 31 | } 32 | 33 | handleResult(step.next()) 34 | return () => subscription?.unsubscribe() 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/core/cpu/README.md: -------------------------------------------------------------------------------- 1 | # CPU 2 | -------------------------------------------------------------------------------- /src/core/cpu/cpu.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'di-wise' 2 | import { type Observable, take } from 'rxjs' 3 | 4 | import { Bus, type ControlLines } from '../bus/bus' 5 | 6 | type AsyncControlGenerator = Generator, T, ControlLines> 7 | 8 | export class Cpu { 9 | private bus = inject(Bus); 10 | 11 | *step(): AsyncControlGenerator { 12 | const x = yield* this.readMemory(0x00) 13 | const y = yield* this.readMemory(0x01) 14 | const result = x + y 15 | yield* this.writeMemory(result, 0x02) 16 | } 17 | 18 | *readMemory(address: number): AsyncControlGenerator { 19 | this.bus.address$.next(address) 20 | this.bus.setControl({ 21 | RD: 0b1, 22 | MREQ: 0b1, 23 | }) 24 | yield this.bus.clockRise$.pipe(take(1)) 25 | this.bus.setControl({ 26 | RD: 0b0, 27 | MREQ: 0b0, 28 | }) 29 | return this.bus.data$.getValue() 30 | } 31 | 32 | *writeMemory(data: number, address: number): AsyncControlGenerator { 33 | this.bus.data$.next(data) 34 | this.bus.address$.next(address) 35 | this.bus.setControl({ 36 | WR: 0b1, 37 | MREQ: 0b1, 38 | }) 39 | yield this.bus.clockRise$.pipe(take(1)) 40 | this.bus.setControl({ 41 | WR: 0b0, 42 | MREQ: 0b0, 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/memory/README.md: -------------------------------------------------------------------------------- 1 | # Memory 2 | -------------------------------------------------------------------------------- /src/core/memory/memory.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'di-wise' 2 | import { filter, map, type Observable, share } from 'rxjs' 3 | 4 | import { Bus } from '../bus/bus' 5 | 6 | export enum MemoryOperationType { 7 | READ = 'READ', 8 | WRITE = 'WRITE', 9 | } 10 | 11 | export interface MemoryOperation { 12 | type: MemoryOperationType 13 | data: number 14 | address: number 15 | } 16 | 17 | export class Memory { 18 | // TODO: use shared constants 19 | private readonly data = new Uint8Array(0x100) 20 | 21 | readonly read$: Observable 22 | readonly write$: Observable 23 | 24 | private bus = inject(Bus) 25 | 26 | constructor() { 27 | const control$ = this.bus.control$.pipe( 28 | filter((control) => control.MREQ), 29 | share(), 30 | ) 31 | 32 | this.read$ = control$.pipe( 33 | filter((control) => control.RD), 34 | map(this.read), 35 | share(), 36 | ) 37 | 38 | this.write$ = control$.pipe( 39 | filter((control) => control.WR), 40 | map(this.write), 41 | share(), 42 | ) 43 | 44 | this.read$.subscribe() 45 | this.write$.subscribe() 46 | } 47 | 48 | private read = (): MemoryOperation => { 49 | const address = this.bus.address$.getValue() 50 | const data = this.data[address] 51 | this.bus.data$.next(data) 52 | return { 53 | type: MemoryOperationType.READ, 54 | data, 55 | address, 56 | } 57 | } 58 | 59 | private write = (): MemoryOperation => { 60 | const address = this.bus.address$.getValue() 61 | const data = this.bus.data$.getValue() 62 | this.data[address] = data 63 | return { 64 | type: MemoryOperationType.WRITE, 65 | data, 66 | address, 67 | } 68 | } 69 | 70 | getData = (): number[] => { 71 | return Array.from(this.data) 72 | } 73 | 74 | subscribeData = (onDataChange: (() => void)): (() => void) => { 75 | const subscription = this.write$.subscribe(onDataChange) 76 | return () => subscription.unsubscribe() 77 | } 78 | 79 | load(data: Uint8Array, offset: number): void { 80 | this.data.set(data, offset) 81 | } 82 | 83 | reset(): void { 84 | this.data.fill(0) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/features/assembler/assemble.ts: -------------------------------------------------------------------------------- 1 | import { store } from '@/app/store' 2 | import { setException } from '@/features/exception/exceptionSlice' 3 | import { initMemoryDataFrom } from '@/features/memory/memorySlice' 4 | 5 | import { resetAssemblerState, setAssemblerError, setAssemblerState } from './assemblerSlice' 6 | import { assemble as assemblePure, AssemblerError, type AssembleResult } from './core' 7 | 8 | export const assemble = (source: string): void => { 9 | let assembleResult: AssembleResult 10 | try { 11 | assembleResult = assemblePure(source) 12 | } 13 | catch (exception) { 14 | if (exception instanceof AssemblerError) { 15 | const assemblerErrorObject = exception.toPlainObject() 16 | store.dispatch(setAssemblerError(assemblerErrorObject)) 17 | } 18 | else { 19 | store.dispatch(setException(exception)) 20 | store.dispatch(resetAssemblerState()) 21 | } 22 | return 23 | } 24 | const [addressToCodeMap, addressToStatementMap] = assembleResult 25 | store.dispatch(setAssemblerState({ source, addressToStatementMap })) 26 | store.dispatch(initMemoryDataFrom(addressToCodeMap)) 27 | } 28 | -------------------------------------------------------------------------------- /src/features/assembler/assemblerSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import type { AddressToStatementMap, AssemblerErrorObject } from './core' 4 | 5 | interface AssemblerState { 6 | error: AssemblerErrorObject | null 7 | source: string 8 | addressToStatementMap: Partial 9 | } 10 | 11 | const initialState: AssemblerState = { 12 | error: null, 13 | source: '', 14 | addressToStatementMap: {}, 15 | } 16 | 17 | export const assemblerSlice = createSlice({ 18 | name: 'assembler', 19 | initialState, 20 | reducers: { 21 | setState: ( 22 | state, 23 | action: PayloadAction<{ 24 | source: string 25 | addressToStatementMap: Partial 26 | }>, 27 | ) => { 28 | state.error = null 29 | state.source = action.payload.source 30 | state.addressToStatementMap = Object.freeze(action.payload.addressToStatementMap) 31 | }, 32 | setError: (state, action: PayloadAction) => { 33 | state.error = action.payload 34 | state.source = '' 35 | state.addressToStatementMap = {} 36 | }, 37 | resetState: () => initialState, 38 | }, 39 | selectors: { 40 | selectAssembledSource: (state) => state.source, 41 | selectAddressToStatementMap: (state) => state.addressToStatementMap, 42 | selectAssemblerError: (state) => state.error, 43 | selectAssemblerErrorRange: (state) => state.error?.range, 44 | }, 45 | }) 46 | 47 | export const { 48 | setState: setAssemblerState, 49 | setError: setAssemblerError, 50 | resetState: resetAssemblerState, 51 | } = assemblerSlice.actions 52 | 53 | export const { 54 | selectAssembledSource, 55 | selectAddressToStatementMap, 56 | selectAssemblerError, 57 | selectAssemblerErrorRange, 58 | } = assemblerSlice.selectors 59 | -------------------------------------------------------------------------------- /src/features/assembler/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Mnemonic, MnemonicToOperandCountMap } from '@/common/constants' 2 | 3 | export interface SourceRange { 4 | from: number 5 | to: number 6 | } 7 | 8 | type MnemonicWithOperandCount< 9 | C extends (typeof MnemonicToOperandCountMap)[Mnemonic], 10 | M extends Mnemonic = Mnemonic, 11 | > = M extends never ? never : (typeof MnemonicToOperandCountMap)[M] extends C ? M : never 12 | 13 | export type MnemonicWithNoOperand = MnemonicWithOperandCount<0> 14 | 15 | export type MnemonicWithOneOperand = MnemonicWithOperandCount<1> 16 | 17 | export type MnemonicWithTwoOperands = MnemonicWithOperandCount<2> 18 | -------------------------------------------------------------------------------- /src/features/controller/ControlButtons.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | 3 | import { useSelector } from '@/app/store' 4 | import { Arrow, Forward, Play, Stop, Undo } from '@/common/components/icons' 5 | import { NO_BREAK_SPACE } from '@/common/constants' 6 | import { classNames } from '@/common/utils' 7 | 8 | import { selectIsRunning, selectIsSuspended } from './controllerSlice' 9 | import { useController } from './hooks' 10 | 11 | type ButtonProps = PropsWithChildren<{ 12 | onClick?: React.MouseEventHandler 13 | disabled?: boolean 14 | }> 15 | 16 | const ControlButton: FC = ({ onClick, disabled = false, children }) => ( 17 |
23 | {children} 24 |
25 | ) 26 | 27 | // TODO: useMemo 28 | const ControlButtons: FC = () => { 29 | const controller = useController() 30 | 31 | const AssembleButton = () => ( 32 | 33 | 34 | Assemble 35 | 36 | ) 37 | 38 | const RunButton = () => { 39 | const isRunning = useSelector(selectIsRunning) 40 | const isSuspended = useSelector(selectIsSuspended) 41 | return ( 42 | 43 | {isRunning 44 | ? ( 45 | <> 46 | 47 | Stop 48 | 49 | ) 50 | : ( 51 | <> 52 | 53 | 54 | Run 55 | {NO_BREAK_SPACE} 56 | 57 | 58 | )} 59 | 60 | ) 61 | } 62 | 63 | const StepButton = () => { 64 | const isRunning = useSelector(selectIsRunning) 65 | const isSuspended = useSelector(selectIsSuspended) 66 | return ( 67 | 70 | 71 | Step 72 | 73 | ) 74 | } 75 | 76 | const ResetButton = () => ( 77 | 78 | 79 | Reset 80 | 81 | ) 82 | 83 | return ( 84 | <> 85 | 86 | 87 | 88 | 89 | 90 | ) 91 | } 92 | 93 | export default ControlButtons 94 | -------------------------------------------------------------------------------- /src/features/controller/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type FC, 4 | type ReactNode, 5 | type RefCallback, 6 | useCallback, 7 | useContext, 8 | } from 'react' 9 | 10 | import { useHover, useRefCallback } from '@/common/hooks' 11 | import { classNames } from '@/common/utils' 12 | 13 | interface MenuContextValue { 14 | currentOpen: HTMLDivElement | null 15 | setCurrentOpen: (menuElement: HTMLDivElement | null) => void 16 | } 17 | 18 | export const MenuContext = createContext({ 19 | currentOpen: null, 20 | setCurrentOpen: () => { 21 | throw new Error('MenuContext not initialized') 22 | }, 23 | }) 24 | 25 | if (import.meta.env.DEV) { 26 | MenuContext.displayName = 'MenuContext' 27 | } 28 | 29 | interface Props { 30 | children: ( 31 | isOpen: boolean, 32 | hoverRef: RefCallback, 33 | menuElement: HTMLDivElement, 34 | ) => ReactNode 35 | } 36 | 37 | const Menu: FC = ({ children }) => { 38 | const [menuElement, menuRef] = useRefCallback() 39 | const isReady = menuElement !== null 40 | 41 | const { currentOpen, setCurrentOpen } = useContext(MenuContext) 42 | const isOpen = currentOpen !== null && currentOpen === menuElement 43 | 44 | const toggleOpen = (): void => { 45 | setCurrentOpen(isOpen ? null : menuElement) 46 | } 47 | 48 | const handleHover = useCallback( 49 | (isHovered: boolean) => { 50 | const hasOtherOpen = currentOpen !== null && currentOpen !== menuElement 51 | if (hasOtherOpen && isHovered) { 52 | setCurrentOpen(menuElement) 53 | } 54 | }, 55 | [currentOpen, menuElement, setCurrentOpen], 56 | ) 57 | const hoverRef = useHover(handleHover) 58 | 59 | return ( 60 |
64 | {isReady && children(isOpen, hoverRef, menuElement)} 65 |
66 | ) 67 | } 68 | 69 | export default Menu 70 | -------------------------------------------------------------------------------- /src/features/controller/MenuButton.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren, Ref } from 'react' 2 | 3 | import { classNames } from '@/common/utils' 4 | 5 | const className = 'flex h-full space-x-2 items-center' 6 | 7 | const MenuButton: FC = ({ children }) => ( 8 |
{children}
9 | ) 10 | 11 | type Props = PropsWithChildren<{ 12 | innerRef?: Ref 13 | }> 14 | 15 | const Main: FC = ({ innerRef, children }) => ( 16 |
17 | {children} 18 |
19 | ) 20 | 21 | if (import.meta.env.DEV) { 22 | Main.displayName = 'MenuButton.Main' 23 | } 24 | 25 | export default Object.assign(MenuButton, { Main }) 26 | -------------------------------------------------------------------------------- /src/features/controller/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, type PropsWithChildren, type ReactNode, type RefCallback, useState } from 'react' 2 | 3 | import Anchor from '@/common/components/Anchor' 4 | import { Play, Share } from '@/common/components/icons' 5 | import { useHover, useRefCallback } from '@/common/hooks' 6 | import { invariant } from '@/common/utils' 7 | 8 | const className = 'flex space-x-4 py-1 px-2 items-center justify-between hover:bg-gray-200' 9 | 10 | type Props = PropsWithChildren<{ 11 | onClick: React.MouseEventHandler 12 | }> 13 | 14 | const MenuItem: FC = ({ onClick, children }) => ( 15 |
16 | {children} 17 | 18 |
19 | ) 20 | 21 | type ExternalLinkProps = PropsWithChildren<{ 22 | href: string 23 | }> 24 | 25 | const ExternalLink: FC = ({ href, children }) => ( 26 | 27 | {children} 28 |
29 | 30 |
31 |
32 | ) 33 | 34 | if (import.meta.env.DEV) { 35 | ExternalLink.displayName = 'MenuItem.ExternalLink' 36 | } 37 | 38 | interface ExpandableProps { 39 | children: ( 40 | isHovered: boolean, 41 | menuItemsRef: RefCallback, 42 | menuItemElement: HTMLDivElement, 43 | ) => ReactNode 44 | } 45 | 46 | const Expandable: FC = ({ children }) => { 47 | const [menuItemElement, menuItemRef] = useRefCallback() 48 | const isReady = menuItemElement !== null 49 | 50 | const [isHovered, setHovered] = useState(false) 51 | const hoverRef = useHover(setHovered, /* delay: */ 100) 52 | 53 | const refCallback: RefCallback = (element) => { 54 | menuItemRef(element) 55 | hoverRef(element) 56 | } 57 | 58 | const [menuItems, menuItemsRef] = useRefCallback() 59 | 60 | const handleClick: React.MouseEventHandler = (event) => { 61 | invariant(event.target instanceof Node) 62 | if (menuItems === null || !menuItems.contains(event.target)) { 63 | event.stopPropagation() 64 | } 65 | } 66 | 67 | return ( 68 |
69 | {isReady && ( 70 | <> 71 |
{children(isHovered, menuItemsRef, menuItemElement)}
72 |
73 | 74 |
75 | 76 | )} 77 |
78 | ) 79 | } 80 | 81 | if (import.meta.env.DEV) { 82 | Expandable.displayName = 'MenuItem.Expandable' 83 | } 84 | 85 | export default Object.assign(MenuItem, { ExternalLink, Expandable }) 86 | -------------------------------------------------------------------------------- /src/features/controller/MenuItems.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren, RefCallback } from 'react' 2 | 3 | const BORDER_WIDTH = 1 4 | 5 | const className = 'divide-y border bg-gray-50 shadow fixed' 6 | 7 | type Props = PropsWithChildren<{ 8 | menuElement: HTMLDivElement 9 | }> 10 | 11 | const MenuItems: FC = ({ menuElement, children }) => { 12 | const refCallback: RefCallback = (element) => { 13 | if (element === null) { 14 | return 15 | } 16 | const { bottom: menuBottom, left: menuLeft } = menuElement.getBoundingClientRect() 17 | element.style.top = `${menuBottom - BORDER_WIDTH}px` 18 | element.style.left = `${menuLeft}px` 19 | } 20 | 21 | return ( 22 |
23 | {children} 24 |
25 | ) 26 | } 27 | 28 | type ExpandedProps = PropsWithChildren<{ 29 | innerRef: RefCallback 30 | menuItemElement: HTMLDivElement 31 | }> 32 | 33 | const Expanded: FC = ({ innerRef, menuItemElement, children }) => { 34 | const refCallback: RefCallback = (element) => { 35 | innerRef(element) 36 | if (element === null) { 37 | return 38 | } 39 | const { top: menuItemTop, right: menuItemRight } = menuItemElement.getBoundingClientRect() 40 | const isParentFirstChild = menuItemElement.offsetTop === 0 41 | element.style.top = `${menuItemTop - (isParentFirstChild ? BORDER_WIDTH : 0)}px` 42 | element.style.left = `${menuItemRight}px` 43 | } 44 | 45 | return ( 46 |
47 | {children} 48 |
49 | ) 50 | } 51 | 52 | if (import.meta.env.DEV) { 53 | Expanded.displayName = 'MenuItems.Expanded' 54 | } 55 | 56 | export default Object.assign(MenuItems, { Expanded }) 57 | -------------------------------------------------------------------------------- /src/features/controller/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useCallback, useMemo, useState } from 'react' 2 | 3 | import Anchor from '@/common/components/Anchor' 4 | import { Github } from '@/common/components/icons' 5 | import { useOutsideClick, useStableHandler } from '@/common/hooks' 6 | 7 | import ConfigurationMenu from './ConfigurationMenu' 8 | import ControlButtons from './ControlButtons' 9 | import FileMenu from './FileMenu' 10 | import HelpMenu from './HelpMenu' 11 | import { MenuContext } from './Menu' 12 | import ViewMenu from './ViewMenu' 13 | 14 | const ToolBar: FC = () => { 15 | const [openMenu, __setOpenMenu] = useState(null) 16 | 17 | const setOpenMenu = useStableHandler((element: HTMLDivElement | null) => { 18 | __setOpenMenu(element) 19 | outsideClickRef(element) 20 | }) 21 | 22 | const menuContextValue = useMemo(() => { 23 | return { 24 | currentOpen: openMenu, 25 | setCurrentOpen: setOpenMenu, 26 | } 27 | }, [openMenu, setOpenMenu]) 28 | 29 | const handleOutsideClick = useCallback(() => { 30 | setOpenMenu(null) 31 | }, [setOpenMenu]) 32 | const outsideClickRef = useOutsideClick(handleOutsideClick) 33 | 34 | return ( 35 |
36 |
37 |

Assembler Simulator

38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | ) 53 | } 54 | 55 | export default ToolBar 56 | -------------------------------------------------------------------------------- /src/features/controller/ViewMenu.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { store, useSelector } from '@/app/store' 4 | import { CheckMark, View as ViewIcon } from '@/common/components/icons' 5 | import { splitCamelCaseToString } from '@/common/utils' 6 | import { ioDeviceNames, selectIoDeviceStates, toggleIoDeviceVisible } from '@/features/io/ioSlice' 7 | import { memoryViewOptions, selectMemoryView, setMemoryView } from '@/features/memory/memorySlice' 8 | 9 | import Menu from './Menu' 10 | import MenuButton from './MenuButton' 11 | import MenuItem from './MenuItem' 12 | import MenuItems from './MenuItems' 13 | 14 | const MemoryMenu: FC = () => { 15 | const memoryView = useSelector(selectMemoryView) 16 | 17 | return ( 18 | 19 | {(isHovered, menuItemsRef, menuItemElement) => ( 20 | <> 21 | 22 | 23 | Memory 24 | 25 | {isHovered && ( 26 | 27 | {memoryViewOptions.map((memoryViewOption, index) => ( 28 | { 31 | store.dispatch(setMemoryView(memoryViewOption)) 32 | }}> 33 | 34 | {memoryView === memoryViewOption ? : } 35 | {memoryViewOption} 36 | 37 | 38 | ))} 39 | 40 | )} 41 | 42 | )} 43 | 44 | ) 45 | } 46 | 47 | const IoMenu: FC = () => { 48 | const ioDeviceStates = useSelector(selectIoDeviceStates) 49 | 50 | return ( 51 | 52 | {(isHovered, menuItemsRef, menuItemElement) => ( 53 | <> 54 | 55 | 56 | I/O Devices 57 | 58 | {isHovered && ( 59 | 60 | {ioDeviceNames.map((name, index) => ( 61 | { 64 | store.dispatch(toggleIoDeviceVisible(name)) 65 | }}> 66 | 67 | {ioDeviceStates[name].isVisible ? : } 68 | {splitCamelCaseToString(name)} 69 | 70 | 71 | ))} 72 | 73 | )} 74 | 75 | )} 76 | 77 | ) 78 | } 79 | 80 | const ViewMenu: FC = () => ( 81 | 82 | {(isOpen, hoverRef, menuElement) => ( 83 | <> 84 | 85 | 86 | View 87 | 88 | {isOpen && ( 89 | 90 | 91 | 92 | 93 | )} 94 | 95 | )} 96 | 97 | ) 98 | 99 | export default ViewMenu 100 | -------------------------------------------------------------------------------- /src/features/controller/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { debounceTime, delayWhen, filter, merge, of, tap, timer } from 'rxjs' 3 | 4 | import { store } from '@/app/store' 5 | import { UPDATE_TIMEOUT_MS } from '@/common/constants' 6 | import { useSingleton } from '@/common/hooks' 7 | import { observe } from '@/common/observe' 8 | import { setAssemblerError, setAssemblerState } from '@/features/assembler/assemblerSlice' 9 | import { setEditorInput } from '@/features/editor/editorSlice' 10 | 11 | import { 12 | selectAutoAssemble, 13 | selectIsRunning, 14 | selectIsSuspended, 15 | selectRuntimeConfiguration, 16 | } from './controllerSlice' 17 | import { Controller } from './core' 18 | 19 | export const useController = (): Controller => { 20 | const controller = useSingleton(() => new Controller()) 21 | 22 | useEffect(() => { 23 | const autoAssemble$ = store.onState(selectAutoAssemble) 24 | return observe( 25 | autoAssemble$.pipe(debounceTime(UPDATE_TIMEOUT_MS), filter(Boolean)), 26 | controller.assemble, 27 | ) 28 | }, [controller]) 29 | 30 | useEffect(() => { 31 | const setEditorInput$ = store.onAction(setEditorInput) 32 | return observe( 33 | setEditorInput$.pipe( 34 | tap(controller.resetSelf), 35 | filter(() => store.getState(selectAutoAssemble)), 36 | delayWhen(({ isFromFile }) => (isFromFile ? timer(UPDATE_TIMEOUT_MS) : of(null))), 37 | ), 38 | controller.assemble, 39 | ) 40 | }, [controller]) 41 | 42 | useEffect(() => { 43 | const setAssemblerState$ = store.onAction(setAssemblerState) 44 | const setAssemblerError$ = store.onAction(setAssemblerError) 45 | return observe(merge(setAssemblerState$, setAssemblerError$), controller.reset) 46 | }, [controller]) 47 | 48 | useEffect(() => { 49 | const runtimeConfiguration$ = store.onState(selectRuntimeConfiguration) 50 | return observe( 51 | runtimeConfiguration$.pipe( 52 | filter(() => { 53 | // `setSuspended` action listener will resume the main loop with new configuration 54 | // so we skip calling `stopAndRun` if cpu is suspended 55 | return store.getState(selectIsRunning) && !store.getState(selectIsSuspended) 56 | }), 57 | ), 58 | controller.stopAndRun, 59 | ) 60 | }, [controller]) 61 | 62 | return controller 63 | } 64 | -------------------------------------------------------------------------------- /src/features/controller/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import { selectAddressToStatementMap } from '@/features/assembler/assemblerSlice' 4 | import { selectCpuInstructionPointerRegister } from '@/features/cpu/cpuSlice' 5 | import { selectMemoryData } from '@/features/memory/memorySlice' 6 | 7 | export const selectCurrentStatementRange = createSelector( 8 | selectCpuInstructionPointerRegister, 9 | selectAddressToStatementMap, 10 | selectMemoryData, 11 | (address, addressToStatementMap, memoryData) => { 12 | const statement = addressToStatementMap[address] 13 | if ( 14 | statement 15 | && statement.codes.length 16 | && statement.codes.every((code, offset) => code === memoryData[address + offset]) 17 | ) { 18 | return statement.range 19 | } 20 | return null 21 | }, 22 | ) 23 | -------------------------------------------------------------------------------- /src/features/cpu/CpuRegisters.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { useSelector } from '@/app/store' 4 | import CardHeader from '@/common/components/CardHeader' 5 | import { curryRight2 } from '@/common/utils' 6 | 7 | import { 8 | GeneralPurposeRegister, 9 | GeneralPurposeRegisterName, 10 | SpecialPurposeRegisterName, 11 | } from './core' 12 | import { 13 | selectCpuGeneralPurposeRegister, 14 | selectCpuInstructionPointerRegister, 15 | selectCpuStackPointerRegister, 16 | selectCpuStatusRegister, 17 | } from './cpuSlice' 18 | import RegisterTableRow from './RegisterTableRow' 19 | 20 | const AlRegisterTableRow: FC = () => { 21 | const al = useSelector(curryRight2(selectCpuGeneralPurposeRegister)(GeneralPurposeRegister.AL)) 22 | return 23 | } 24 | 25 | const BlRegisterTableRow: FC = () => { 26 | const bl = useSelector(curryRight2(selectCpuGeneralPurposeRegister)(GeneralPurposeRegister.BL)) 27 | return 28 | } 29 | 30 | const ClRegisterTableRow: FC = () => { 31 | const cl = useSelector(curryRight2(selectCpuGeneralPurposeRegister)(GeneralPurposeRegister.CL)) 32 | return 33 | } 34 | 35 | const DlRegisterTableRow: FC = () => { 36 | const dl = useSelector(curryRight2(selectCpuGeneralPurposeRegister)(GeneralPurposeRegister.DL)) 37 | return 38 | } 39 | 40 | const InstructionPointerRegisterTableRow: FC = () => { 41 | const ip = useSelector(selectCpuInstructionPointerRegister) 42 | return ( 43 | 48 | ) 49 | } 50 | 51 | const StackPointerRegisterTableRow: FC = () => { 52 | const sp = useSelector(selectCpuStackPointerRegister) 53 | return ( 54 | 59 | ) 60 | } 61 | 62 | const StatusRegisterTableRow: FC = () => { 63 | const sr = useSelector(selectCpuStatusRegister) 64 | return 65 | } 66 | 67 | const CpuRegisters: FC = () => ( 68 |
69 | 70 |
71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 |
89 | ) 90 | 91 | export default CpuRegisters 92 | -------------------------------------------------------------------------------- /src/features/cpu/RegisterTableRow.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { NO_BREAK_SPACE } from '@/common/constants' 4 | import { decToBin, decToHex, sign8 } from '@/common/utils' 5 | 6 | import type { RegisterName } from './core' 7 | import RegisterValueTableCell, { RadixLabel } from './RegisterValueTableCell' 8 | 9 | interface Props { 10 | name: RegisterName 11 | value: number 12 | valueClassName?: string 13 | } 14 | 15 | const RegisterTableRow: FC = ({ name, value, valueClassName }) => { 16 | const hexValue = decToHex(value) 17 | const binValue = decToBin(value) 18 | 19 | const signedValue = sign8(value) 20 | const decValue = `${signedValue >= 0 ? '+' : '-'}${`${Math.abs(signedValue)}`.padStart(3, '0')}` 21 | 22 | return ( 23 | 24 | {name} 25 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | const FlagIndicator: FC = () => ( 37 | 38 | {NO_BREAK_SPACE} 39 | 40 | 41 | 42 | 43 | ) 44 | 45 | if (import.meta.env.DEV) { 46 | FlagIndicator.displayName = 'RegisterTableRow.FlagIndicator' 47 | } 48 | 49 | export default Object.assign(RegisterTableRow, { FlagIndicator }) 50 | -------------------------------------------------------------------------------- /src/features/cpu/RegisterValueTableCell.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { NO_BREAK_SPACE } from '@/common/constants' 4 | import { classNames } from '@/common/utils' 5 | 6 | export enum RadixLabel { 7 | Hex = 'hex', 8 | Bin = 'bin', 9 | Dec = 'dec', 10 | } 11 | 12 | interface Props { 13 | label: RadixLabel 14 | value: string 15 | valueClassName?: string 16 | } 17 | 18 | const RegisterValueTableCell: FC = ({ label, value, valueClassName }) => ( 19 | 20 |
21 | {label} 22 | {value} 23 |
24 | 25 | ) 26 | 27 | const FlagIndicator: FC = () => ( 28 | 29 |
30 | {NO_BREAK_SPACE.repeat(3)} 31 | {`${NO_BREAK_SPACE.repeat(3)}ISOZ${NO_BREAK_SPACE}`} 32 |
33 | 34 | ) 35 | 36 | if (import.meta.env.DEV) { 37 | FlagIndicator.displayName = 'RegisterValueTableCell.FlagIndicator' 38 | } 39 | 40 | export default Object.assign(RegisterValueTableCell, { FlagIndicator }) 41 | -------------------------------------------------------------------------------- /src/features/cpu/core/changes.ts: -------------------------------------------------------------------------------- 1 | import type { Registers } from './index' 2 | 3 | interface Change { 4 | // TODO: { from: number, to: number } 5 | value: number 6 | } 7 | 8 | export interface RegisterChange extends Change { 9 | name?: string 10 | /** has interrupt flag changed */ 11 | interrupt?: true 12 | } 13 | 14 | type RegisterChanges = { 15 | [name in keyof Registers]?: RegisterChange 16 | } 17 | 18 | export interface MemoryDataChange extends Change { 19 | address: number 20 | } 21 | 22 | export interface StepChanges { 23 | cpuRegisters?: RegisterChanges 24 | memoryData?: MemoryDataChange 25 | } 26 | -------------------------------------------------------------------------------- /src/features/cpu/core/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_SP = 0xbf 2 | 3 | export enum GeneralPurposeRegisterName { 4 | AL = 'AL', 5 | BL = 'BL', 6 | CL = 'CL', 7 | DL = 'DL', 8 | } 9 | 10 | export enum GeneralPurposeRegister { 11 | AL, 12 | BL, 13 | CL, 14 | DL, 15 | } 16 | 17 | export enum SpecialPurposeRegisterName { 18 | IP = 'IP', 19 | SP = 'SP', 20 | SR = 'SR', 21 | } 22 | 23 | export type RegisterName = GeneralPurposeRegisterName | SpecialPurposeRegisterName 24 | -------------------------------------------------------------------------------- /src/features/cpu/core/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { type ErrorObject, errorToPlainObject } from '@/common/error' 2 | import { decToHex } from '@/common/utils' 3 | 4 | export interface RuntimeErrorObject extends ErrorObject {} 5 | 6 | export abstract class RuntimeError extends Error { 7 | public override name = 'RuntimeError' 8 | 9 | // istanbul ignore next 10 | public toPlainObject(): RuntimeErrorObject { 11 | return errorToPlainObject(this) 12 | } 13 | } 14 | 15 | export class InvalidRegisterError extends RuntimeError { 16 | constructor(value: number) { 17 | super(`Invalid register '${decToHex(value)}'.`) 18 | } 19 | } 20 | 21 | export class RunBeyondEndOfMemoryError extends RuntimeError { 22 | constructor() { 23 | super('Can not execute code beyond the end of RAM.') 24 | } 25 | } 26 | 27 | export class StackOverflowError extends RuntimeError { 28 | constructor() { 29 | super('Stack overflow.') 30 | } 31 | } 32 | 33 | export class StackUnderflowError extends RuntimeError { 34 | constructor() { 35 | super('Stack underflow.') 36 | } 37 | } 38 | 39 | export class DivideByZeroError extends RuntimeError { 40 | constructor() { 41 | super('Can not divide by zero.') 42 | } 43 | } 44 | 45 | export class InvalidPortError extends RuntimeError { 46 | constructor(port: number) { 47 | super(`I/O ports between 0 and F are available, got '${decToHex(port)}'.`) 48 | } 49 | } 50 | 51 | export class InvalidInputDataError extends RuntimeError { 52 | constructor(content: number) { 53 | super(`Input data '${decToHex(content)}' is greater than FF.`) 54 | } 55 | } 56 | 57 | export class InvalidOpcodeError extends RuntimeError { 58 | constructor(opcode: number) { 59 | super(`Invalid opcode '${decToHex(opcode)}'.`) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/features/cpu/core/operations.ts: -------------------------------------------------------------------------------- 1 | import { DivideByZeroError } from './exceptions' 2 | 3 | export const add = (addend: number, augend: number): number => augend + addend 4 | 5 | export const subtract = (subtrahend: number, minuend: number): number => minuend - subtrahend 6 | 7 | export const multiply = (multiplier: number, multiplicand: number): number => 8 | multiplicand * multiplier 9 | 10 | const checkDivisor = (value: number): number => { 11 | if (value === 0) { 12 | throw new DivideByZeroError() 13 | } 14 | return value 15 | } 16 | 17 | export const divide = (divisor: number, dividend: number): number => 18 | Math.floor(dividend / checkDivisor(divisor)) 19 | 20 | export const increase = (n: number): number => add(1, n) 21 | 22 | export const decrease = (n: number): number => subtract(1, n) 23 | 24 | export const modulo = (divisor: number, dividend: number): number => 25 | dividend % checkDivisor(divisor) 26 | 27 | /** 28 | * @returns {number} b & a 29 | */ 30 | export const and = (a: number, b: number): number => b & a 31 | 32 | /** 33 | * @returns {number} b | a 34 | */ 35 | export const or = (a: number, b: number): number => b | a 36 | 37 | /** 38 | * @returns {number} b ^ a 39 | */ 40 | export const xor = (a: number, b: number): number => b ^ a 41 | 42 | export const not = (n: number): number => ~n 43 | 44 | export const rol = (n: number): number => (n << 1) | (n >> 7) 45 | 46 | export const ror = (n: number): number => (n >> 1) | (n << 7) 47 | 48 | export const shl = (n: number): number => n << 1 49 | 50 | export const shr = (n: number): number => n >> 1 51 | -------------------------------------------------------------------------------- /src/features/cpu/cpuSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { 4 | type GeneralPurposeRegister, 5 | initRegisters, 6 | type Registers, 7 | type RuntimeErrorObject, 8 | } from './core' 9 | 10 | interface Status { 11 | fault: RuntimeErrorObject | null 12 | halted: boolean 13 | } 14 | 15 | interface CpuState { 16 | status: Status 17 | registers: Registers 18 | } 19 | 20 | const initialState: CpuState = { 21 | status: { 22 | fault: null, 23 | halted: false, 24 | }, 25 | registers: initRegisters(), 26 | } 27 | 28 | const createTypedStateSelector = createSelector.withTypes() 29 | 30 | export const cpuSlice = createSlice({ 31 | name: 'cpu', 32 | initialState, 33 | reducers: { 34 | setFault: (state, action: PayloadAction) => { 35 | state.status.fault = action.payload 36 | }, 37 | setHalted: (state) => { 38 | state.status.halted = true 39 | }, 40 | setRegisters: (state, action: PayloadAction) => { 41 | state.registers = action.payload 42 | }, 43 | resetState: () => initialState, 44 | }, 45 | selectors: { 46 | selectCpuStatus: (state) => state.status, 47 | selectCpuFault: (state) => state.status.fault, 48 | selectCpuRegisters: (state) => state.registers, 49 | selectCpuGeneralPurposeRegister: createTypedStateSelector( 50 | [(state) => state.registers.gpr, (_, code: GeneralPurposeRegister) => code], 51 | (gpr, code) => gpr[code], 52 | ), 53 | selectCpuInstructionPointerRegister: (state) => state.registers.ip, 54 | selectCpuStackPointerRegister: (state) => state.registers.sp, 55 | selectCpuPointerRegisters: createTypedStateSelector( 56 | [(state) => state.registers.ip, (state) => state.registers.sp], 57 | (ip, sp) => ({ ip, sp }), 58 | ), 59 | selectCpuStatusRegister: (state) => state.registers.sr, 60 | }, 61 | }) 62 | 63 | export const { 64 | setFault: setCpuFault, 65 | setHalted: setCpuHalted, 66 | setRegisters: setCpuRegisters, 67 | resetState: resetCpuState, 68 | } = cpuSlice.actions 69 | 70 | export const { 71 | selectCpuStatus, 72 | selectCpuFault, 73 | selectCpuRegisters, 74 | selectCpuGeneralPurposeRegister, 75 | selectCpuInstructionPointerRegister, 76 | selectCpuStackPointerRegister, 77 | selectCpuPointerRegisters, 78 | selectCpuStatusRegister, 79 | } = cpuSlice.selectors 80 | -------------------------------------------------------------------------------- /src/features/editor/CodeMirror.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { classNames } from '@/common/utils' 4 | 5 | import { effects } from './effects' 6 | import { setContainer, useViewEffect } from './hooks' 7 | 8 | interface Props { 9 | className?: string 10 | } 11 | 12 | const CodeMirror: FC = ({ className }) => { 13 | useViewEffect(effects) 14 | return ( 15 |
19 | ) 20 | } 21 | 22 | export default CodeMirror 23 | -------------------------------------------------------------------------------- /src/features/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import CodeMirror from './CodeMirror' 4 | import EditorMessage from './EditorMessage' 5 | 6 | const Editor: FC = () => ( 7 |
8 | 9 | 10 |
11 | ) 12 | 13 | export default Editor 14 | -------------------------------------------------------------------------------- /src/features/editor/EditorMessage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { classNames } from '@/common/utils' 4 | 5 | import { MessageType } from './editorSlice' 6 | import { useMessage } from './hooks' 7 | 8 | const getClassNameFrom = (type: MessageType): string => { 9 | switch (type) { 10 | case MessageType.Error: 11 | return 'bg-red-500' 12 | case MessageType.Warning: 13 | return 'bg-yellow-500' 14 | case MessageType.Info: 15 | return 'bg-blue-500' 16 | } 17 | } 18 | 19 | const EditorMessage: FC = () => { 20 | const message = useMessage() 21 | 22 | if (!message) { 23 | return null 24 | } 25 | 26 | return ( 27 |
28 | {message.content} 29 |
30 | ) 31 | } 32 | 33 | export default EditorMessage 34 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/annotations.ts: -------------------------------------------------------------------------------- 1 | import { Annotation, type Transaction, type TransactionSpec } from '@codemirror/state' 2 | 3 | const StringAnnotation = Annotation.define() 4 | 5 | export const withStringAnnotation = (value: string) => 6 | (transaction: TransactionSpec): TransactionSpec => { 7 | const { annotations = [] } = transaction 8 | return { 9 | ...transaction, 10 | annotations: [...[annotations].flat(), StringAnnotation.of(value)], 11 | } 12 | } 13 | 14 | export const hasStringAnnotation = (value: string) => 15 | (transaction: Transaction): boolean => 16 | transaction.annotation(StringAnnotation) === value 17 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/classNames.ts: -------------------------------------------------------------------------------- 1 | export enum ClassName { 2 | Breakpoint = 'cm-breakpoint', 3 | ActiveLine = 'cm-activeLine', 4 | ActiveLineGutter = 'cm-activeLineGutter', 5 | HighlightLineDefault = 'cm-highlightLine', 6 | HighlightLineTransparent = 'cm-highlightLine--transparent', 7 | WavyUnderline = 'cm-wavyUnderline', 8 | } 9 | 10 | export enum InternalClassName { 11 | Cursor = 'cm-cursor', 12 | CursorPrimary = 'cm-cursor-primary', 13 | Focused = 'cm-focused', 14 | Scroller = 'cm-scroller', 15 | Gutters = 'cm-gutters', 16 | GutterElement = 'cm-gutterElement', 17 | SelectionMatch = 'cm-selectionMatch', 18 | LineNumbers = 'cm-lineNumbers', 19 | } 20 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/exceptionSink.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | const __exceptionSink = EditorView.exceptionSink 5 | 6 | export type ExceptionHandler = (exception: unknown) => void 7 | type ExceptionSink = (handler: ExceptionHandler) => Extension 8 | 9 | export const exceptionSink: ExceptionSink = (handler) => __exceptionSink.of(handler) 10 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/gutter.ts: -------------------------------------------------------------------------------- 1 | import type { gutter } from '@codemirror/view' 2 | 3 | type GutterConfig = Required[0]> 4 | 5 | type DOMEventHandlers = GutterConfig['domEventHandlers'] 6 | 7 | type DOMEventName = keyof DOMEventHandlers 8 | 9 | export type DOMEventHandler = DOMEventHandlers[DOMEventName] 10 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/highlightActiveLine.ts: -------------------------------------------------------------------------------- 1 | // Hide decorations if any non-empty selection exists 2 | // https://github.com/codemirror/view/blob/5989c150d65172c36917d8bd2ea04316a79f20ed/src/active-line.ts 3 | // MIT Licensed https://github.com/codemirror/view/blob/5989c150d65172c36917d8bd2ea04316a79f20ed/LICENSE 4 | 5 | import type { Extension, Range } from '@codemirror/state' 6 | import { 7 | Decoration, 8 | type DecorationSet, 9 | EditorView, 10 | ViewPlugin, 11 | type ViewUpdate, 12 | } from '@codemirror/view' 13 | 14 | import { ClassName, InternalClassName } from './classNames' 15 | 16 | const lineDecoration = Decoration.line({ class: ClassName.ActiveLine }) 17 | 18 | const highlightActiveLinePlugin = ViewPlugin.fromClass( 19 | class PluginValue { 20 | private _decorations: DecorationSet 21 | 22 | public get decorations(): DecorationSet { 23 | return this._decorations 24 | } 25 | 26 | constructor(view: EditorView) { 27 | this._decorations = this.getDecorations(view) 28 | } 29 | 30 | public update(update: ViewUpdate): void { 31 | if (update.docChanged || update.selectionSet) { 32 | this._decorations = this.getDecorations(update.view) 33 | } 34 | } 35 | 36 | private getDecorations(view: EditorView): DecorationSet { 37 | const selectionRanges = view.state.selection.ranges 38 | if (selectionRanges.some((selectionRange) => !selectionRange.empty)) { 39 | return Decoration.none 40 | } 41 | const decorationRanges: Array> = [] 42 | const rangeCount = selectionRanges.length 43 | for (let lastLineFrom = -1, rangeIndex = 0; rangeIndex < rangeCount; rangeIndex++) { 44 | const selectionRange = selectionRanges[rangeIndex] 45 | const line = view.state.doc.lineAt(selectionRange.head) 46 | if (line.from > lastLineFrom) { 47 | decorationRanges.push(lineDecoration.range(line.from)) 48 | lastLineFrom = line.from 49 | } 50 | } 51 | return Decoration.set(decorationRanges) 52 | } 53 | }, 54 | { 55 | decorations: (pluginValue) => pluginValue.decorations, 56 | }, 57 | ) 58 | 59 | export const highlightActiveLine = (): Extension => { 60 | return [ 61 | highlightActiveLinePlugin, 62 | EditorView.baseTheme({ 63 | [`&.${InternalClassName.Focused} .${ClassName.ActiveLine}`]: { 64 | boxShadow: 'inset 0 0 0 2px #e5e7eb', 65 | }, 66 | [`.${ClassName.ActiveLine}`]: { 67 | backgroundColor: 'unset', 68 | }, 69 | }), 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/highlightActiveLineGutter.ts: -------------------------------------------------------------------------------- 1 | // Hide markers if any non-empty selection exists 2 | // https://github.com/codemirror/view/blob/d67284f146b03cf78b73d1d03e703301522ab574/src/gutter.ts#L443-L464 3 | // MIT Licensed https://github.com/codemirror/view/blob/d67284f146b03cf78b73d1d03e703301522ab574/LICENSE 4 | 5 | import { type Extension, type Range, RangeSet } from '@codemirror/state' 6 | import { EditorView, gutterLineClass, GutterMarker } from '@codemirror/view' 7 | 8 | import { ClassName, InternalClassName } from './classNames' 9 | 10 | class ActiveLineGutterMarker extends GutterMarker { 11 | public override elementClass = ClassName.ActiveLineGutter 12 | } 13 | 14 | const activeLineGutterMarker = new ActiveLineGutterMarker() 15 | 16 | const activeLineGutterHighlighter = gutterLineClass.compute(['selection'], (state) => { 17 | const selectionRanges = state.selection.ranges 18 | if (selectionRanges.some((selectionRange) => !selectionRange.empty)) { 19 | return RangeSet.empty 20 | } 21 | const markerRanges: Array> = [] 22 | const rangeCount = selectionRanges.length 23 | for (let lastLineFrom = -1, rangeIndex = 0; rangeIndex < rangeCount; rangeIndex++) { 24 | const selectionRange = selectionRanges[rangeIndex] 25 | const line = state.doc.lineAt(selectionRange.head) 26 | if (line.from > lastLineFrom) { 27 | markerRanges.push(activeLineGutterMarker.range(line.from)) 28 | lastLineFrom = line.from 29 | } 30 | } 31 | return RangeSet.of(markerRanges) 32 | }) 33 | 34 | export const highlightActiveLineGutter = (): Extension => { 35 | return [ 36 | activeLineGutterHighlighter, 37 | EditorView.baseTheme({ 38 | [`&.${InternalClassName.Focused} .${ClassName.ActiveLineGutter}`]: { 39 | color: '#4b5563', // gray-600 40 | }, 41 | [`.${ClassName.ActiveLineGutter}`]: { 42 | backgroundColor: 'unset', 43 | }, 44 | }), 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/highlightLine.ts: -------------------------------------------------------------------------------- 1 | import { type Extension, StateEffect, StateField } from '@codemirror/state' 2 | import { Decoration, type DecorationSet, EditorView } from '@codemirror/view' 3 | import { filterEffects, mapEffectValue, reduceRangeSet } from '@codemirror-toolkit/utils' 4 | 5 | import type { Maybe } from '@/common/maybe' 6 | 7 | import { ClassName } from './classNames' 8 | import type { RangeSetUpdateFilter } from './rangeSet' 9 | import { hasNonEmptySelectionAtLine } from './text' 10 | 11 | export const HighlightLineEffect = StateEffect.define<{ 12 | pos: Maybe 13 | filter?: RangeSetUpdateFilter 14 | }>({ 15 | map({ pos: maybePos, filter }, change) { 16 | return { 17 | pos: maybePos.map((pos) => change.mapPos(pos)), 18 | filter, 19 | } 20 | }, 21 | }) 22 | 23 | const lineDecoration = Decoration.line({ class: ClassName.HighlightLineDefault }) 24 | const lineDecorationTransparent = Decoration.line({ class: ClassName.HighlightLineTransparent }) 25 | 26 | const highlightLineField = StateField.define({ 27 | create() { 28 | return Decoration.none 29 | }, 30 | update(__decorations, transaction) { 31 | const decorations = __decorations.map(transaction.changes) 32 | const updatedDecorations = reduceRangeSet( 33 | decorations, 34 | (resultDecorations, decoration, decorationFrom) => { 35 | const hasNewOverlappedSelection 36 | = transaction.selection !== undefined 37 | && hasNonEmptySelectionAtLine( 38 | transaction.state.doc.lineAt(decorationFrom), 39 | transaction.selection.ranges, 40 | ) 41 | const expectedLineDecoration = hasNewOverlappedSelection 42 | ? lineDecorationTransparent 43 | : lineDecoration 44 | return decoration.eq(expectedLineDecoration) 45 | ? resultDecorations 46 | : resultDecorations.update({ 47 | add: [expectedLineDecoration.range(decorationFrom)], 48 | filter: (from) => from !== decorationFrom, 49 | }) 50 | }, 51 | decorations, 52 | ) 53 | return filterEffects(transaction.effects, HighlightLineEffect).reduce( 54 | (resultDecorations, effect) => 55 | mapEffectValue(effect, ({ pos: maybePos, filter }) => 56 | resultDecorations.update({ 57 | add: maybePos 58 | .map((pos) => { 59 | const hasOverlappedSelection = hasNonEmptySelectionAtLine( 60 | transaction.state.doc.lineAt(pos), 61 | transaction.state.selection.ranges, 62 | ) 63 | const newLineDecoration = hasOverlappedSelection 64 | ? lineDecorationTransparent 65 | : lineDecoration 66 | return [newLineDecoration.range(pos)] 67 | }) 68 | .extract(), 69 | filter, 70 | }), 71 | ), 72 | updatedDecorations, 73 | ) 74 | }, 75 | provide: (thisField) => EditorView.decorations.from(thisField), 76 | }) 77 | 78 | export const highlightLine = (): Extension => { 79 | return [ 80 | highlightLineField, 81 | EditorView.baseTheme({ 82 | [`.${ClassName.HighlightLineDefault}`]: { 83 | backgroundColor: '#dcfce7 !important', 84 | }, 85 | [`.${ClassName.HighlightLineTransparent}`]: { 86 | backgroundColor: '#dcfce780 !important', 87 | }, 88 | }), 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/highlightSelectionMatches.ts: -------------------------------------------------------------------------------- 1 | import { highlightSelectionMatches as __highlightSelectionMatches } from '@codemirror/search' 2 | import type { Extension } from '@codemirror/state' 3 | import { EditorView } from '@codemirror/view' 4 | 5 | import { InternalClassName } from './classNames' 6 | 7 | export const highlightSelectionMatches = (): Extension => { 8 | return [ 9 | EditorView.baseTheme({ 10 | [`.${InternalClassName.SelectionMatch}`]: { 11 | backgroundColor: '#bdcfe480', 12 | }, 13 | }), 14 | __highlightSelectionMatches(), 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/indentWithTab.ts: -------------------------------------------------------------------------------- 1 | import { indentLess, insertTab } from '@codemirror/commands' 2 | import type { KeyBinding } from '@codemirror/view' 3 | 4 | export const indentWithTab: KeyBinding = { 5 | key: 'Tab', 6 | run: insertTab, 7 | shift: indentLess, 8 | } 9 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/lineNumbers.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView, lineNumbers as __lineNumbers } from '@codemirror/view' 3 | 4 | import { invariant } from '@/common/utils' 5 | 6 | import { toggleBreakpointOnMouseEvent } from './breakpoints' 7 | import { InternalClassName } from './classNames' 8 | import type { DOMEventHandler as GutterDOMEventHandler } from './gutter' 9 | 10 | const toggleBreakpointOnStrictMouseEvent: GutterDOMEventHandler = (view, line, event) => { 11 | invariant(event.target instanceof Element) 12 | // only when clicking on a gutter element 13 | if (event.target.classList.contains(InternalClassName.GutterElement)) { 14 | return toggleBreakpointOnMouseEvent(view, line, event) 15 | } 16 | return false 17 | } 18 | 19 | export const lineNumbers = (): Extension => { 20 | return [ 21 | __lineNumbers({ 22 | domEventHandlers: { 23 | mousedown: toggleBreakpointOnStrictMouseEvent, 24 | }, 25 | }), 26 | EditorView.baseTheme({ 27 | [`.${InternalClassName.LineNumbers}`]: { 28 | paddingRight: '6px', 29 | }, 30 | [`.${InternalClassName.LineNumbers} .${InternalClassName.GutterElement}`]: { 31 | cursor: 'pointer', 32 | }, 33 | }), 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/observable.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView, ViewUpdate } from '@codemirror/view' 2 | import { addUpdateListener } from '@codemirror-toolkit/extensions' 3 | import { Observable } from 'rxjs' 4 | 5 | export const onUpdate = (view: EditorView): Observable => 6 | new Observable((subscriber) => { 7 | return addUpdateListener(view, (update) => { 8 | subscriber.next(update) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/rangeSet.ts: -------------------------------------------------------------------------------- 1 | import type { RangeSet, RangeValue } from '@codemirror/state' 2 | 3 | type RangeSetUpdate = Parameters['update']>[0] 4 | 5 | export type RangeSetUpdateFilter = RangeSetUpdate['filter'] 6 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/setup.ts: -------------------------------------------------------------------------------- 1 | import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' 2 | import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' 3 | import type { Extension } from '@codemirror/state' 4 | import { drawSelection, keymap } from '@codemirror/view' 5 | import { extensionManager, updateListener } from '@codemirror-toolkit/extensions' 6 | 7 | import { asm } from './asm' 8 | import { breakpoints } from './breakpoints' 9 | import { highlightActiveLine } from './highlightActiveLine' 10 | import { highlightActiveLineGutter } from './highlightActiveLineGutter' 11 | import { highlightLine } from './highlightLine' 12 | import { highlightSelectionMatches } from './highlightSelectionMatches' 13 | import { indentWithTab } from './indentWithTab' 14 | import { lineNumbers } from './lineNumbers' 15 | import { theme } from './theme' 16 | import { wavyUnderline } from './wavyUnderline' 17 | 18 | export const setup = (): Extension => { 19 | return [ 20 | drawSelection(), 21 | history(), 22 | closeBrackets(), 23 | updateListener(), 24 | extensionManager(), 25 | theme(), 26 | asm(), 27 | highlightActiveLine(), 28 | highlightSelectionMatches(), 29 | highlightActiveLineGutter(), 30 | breakpoints(), 31 | lineNumbers(), 32 | wavyUnderline(), 33 | highlightLine(), 34 | keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, indentWithTab]), 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/state.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState, TransactionSpec } from '@codemirror/state' 2 | 3 | export const replaceContent = (state: EditorState, content: string): TransactionSpec => { 4 | const endIndex = state.doc.length 5 | return { 6 | changes: { 7 | from: 0, 8 | to: endIndex, 9 | insert: content, 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/text.ts: -------------------------------------------------------------------------------- 1 | import type { Line, SelectionRange, Text } from '@codemirror/state' 2 | 3 | export type LineLoc = Pick 4 | 5 | export const lineLocAt = (text: Text, pos: number): LineLoc => { 6 | const { from, to, number } = text.lineAt(pos) 7 | return { from, to, number } 8 | } 9 | 10 | type LineRange = Pick 11 | 12 | type LineRangeComparator = (a: LineRange, b: LineRange) => boolean 13 | 14 | export const lineRangesEqual: LineRangeComparator = (a, b) => a.from === b.from && a.to === b.to 15 | 16 | export const lineRangesOverlap: LineRangeComparator = (a, b) => a.from < b.to && a.to > b.from 17 | 18 | export const hasNonEmptySelectionAtLine = ( 19 | line: Line, 20 | selectionRanges: readonly SelectionRange[], 21 | ): boolean => 22 | selectionRanges.some( 23 | (selectionRange) => 24 | !selectionRange.empty && selectionRange.from < line.to && selectionRange.to > line.from, 25 | ) 26 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/theme.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | import { InternalClassName } from './classNames' 5 | 6 | export const theme = (): Extension => { 7 | return EditorView.theme({ 8 | '&': { 9 | height: '100%', 10 | }, 11 | [`&.${InternalClassName.Focused}`]: { 12 | outline: '0', 13 | }, 14 | [`.${InternalClassName.Scroller}`]: { 15 | cursor: 'text', 16 | fontFamily: "'Jetbrains Mono', monospace", 17 | }, 18 | [`.${InternalClassName.Gutters}`]: { 19 | borderRight: '1px solid #e5e7eb', 20 | backgroundColor: '#f3f4f6', // gray-100 21 | cursor: 'initial', 22 | color: '#9ca3af', // gray-400 23 | }, 24 | [`.${InternalClassName.Cursor}`]: { 25 | borderLeft: '2px solid black', 26 | }, 27 | [`&:not(.${InternalClassName.Focused}) .${InternalClassName.CursorPrimary}`]: { 28 | outline: '0 !important', 29 | }, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/vim.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import type { EditorView } from '@codemirror/view' 3 | import { addExtension, removeExtension } from '@codemirror-toolkit/extensions' 4 | import { BehaviorSubject, filter, from, map, tap } from 'rxjs' 5 | 6 | const vim$ = new BehaviorSubject(null) 7 | 8 | export function enableVim(view: EditorView) { 9 | return from(import('@replit/codemirror-vim')).pipe( 10 | map(({ vim }) => vim({ status: true })), 11 | tap((ext) => vim$.next(ext)), 12 | tap((ext) => addExtension(view, ext)), 13 | ) 14 | } 15 | 16 | export function disableVim(view: EditorView) { 17 | return vim$.pipe( 18 | filter(Boolean), 19 | tap(() => vim$.next(null)), 20 | tap((ext) => removeExtension(view, ext)), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/wavyUnderline.ts: -------------------------------------------------------------------------------- 1 | import { type Extension, StateEffect, StateField } from '@codemirror/state' 2 | import { Decoration, type DecorationSet, EditorView } from '@codemirror/view' 3 | import { filterEffects, mapEffectValue } from '@codemirror-toolkit/utils' 4 | 5 | import type { Maybe } from '@/common/maybe' 6 | 7 | import { ClassName } from './classNames' 8 | import type { RangeSetUpdateFilter } from './rangeSet' 9 | 10 | export const WavyUnderlineEffect = StateEffect.define<{ 11 | range: Maybe<{ from: number, to: number }> 12 | filter?: RangeSetUpdateFilter 13 | }>({ 14 | map({ range: maybeRange, filter }, change) { 15 | return { 16 | range: maybeRange.map(({ from, to }) => ({ 17 | from: change.mapPos(from), 18 | to: change.mapPos(to), 19 | })), 20 | filter, 21 | } 22 | }, 23 | }) 24 | 25 | const markDecoration = Decoration.mark({ class: ClassName.WavyUnderline }) 26 | 27 | const wavyUnderlineField = StateField.define({ 28 | create() { 29 | return Decoration.none 30 | }, 31 | update(__decorations, transaction) { 32 | const decorations = __decorations.map(transaction.changes) 33 | return filterEffects(transaction.effects, WavyUnderlineEffect).reduce( 34 | (resultDecorations, effect) => 35 | mapEffectValue(effect, ({ range: maybeRange, filter }) => 36 | resultDecorations.update({ 37 | add: maybeRange.map(({ from, to }) => [markDecoration.range(from, to)]).extract(), 38 | filter, 39 | }), 40 | ), 41 | decorations, 42 | ) 43 | }, 44 | provide: (thisField) => EditorView.decorations.from(thisField), 45 | }) 46 | 47 | const WAVY_UNDERLINE_IMAGE = `url('data:image/svg+xml;base64,${window.btoa( 48 | ` 49 | 50 | `, 51 | )}')` 52 | 53 | export const wavyUnderline = (): Extension => { 54 | return [ 55 | wavyUnderlineField, 56 | EditorView.baseTheme({ 57 | [`.${ClassName.WavyUnderline}`]: { 58 | background: `${WAVY_UNDERLINE_IMAGE} left bottom repeat-x`, 59 | paddingBottom: '2px', 60 | }, 61 | }), 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /src/features/editor/editorSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { type LineLoc, lineRangesEqual } from './codemirror/text' 4 | import { examples } from './examples' 5 | 6 | export enum MessageType { 7 | Info, 8 | Warning, 9 | Error, 10 | } 11 | 12 | export interface EditorMessage { 13 | type: MessageType 14 | content: string 15 | } 16 | 17 | interface EditorState { 18 | input: string 19 | breakpoints: LineLoc[] 20 | message: EditorMessage | null 21 | } 22 | 23 | const initialState: EditorState = { 24 | input: examples[/* Visual Display Unit */ 4].content, 25 | breakpoints: [], 26 | message: null, 27 | } 28 | 29 | const createTypedStateSelector = createSelector.withTypes() 30 | 31 | export const editorSlice = createSlice({ 32 | name: 'editor', 33 | initialState, 34 | reducers: { 35 | setInput: { 36 | reducer: (state, action: PayloadAction<{ value: string }>) => { 37 | state.input = action.payload.value 38 | }, 39 | prepare: (payload: { value: string, isFromFile?: boolean }) => { 40 | const { value, isFromFile = false } = payload 41 | return { 42 | payload: { value, isFromFile }, 43 | } 44 | }, 45 | }, 46 | setBreakpoints: (state, action: PayloadAction) => { 47 | state.breakpoints = action.payload 48 | }, 49 | addBreakpoint: (state, action: PayloadAction) => { 50 | const targetLineLoc = action.payload 51 | const targetIndex = state.breakpoints.findIndex( 52 | (lineLoc) => lineLoc.from > targetLineLoc.from, 53 | ) 54 | if (targetIndex === -1) { 55 | state.breakpoints.push(targetLineLoc) 56 | } 57 | else { 58 | state.breakpoints.splice(targetIndex, 0, targetLineLoc) 59 | } 60 | }, 61 | removeBreakpoint: (state, action: PayloadAction) => { 62 | const targetLineLoc = action.payload 63 | const targetIndex = state.breakpoints.findIndex((lineLoc) => 64 | lineRangesEqual(lineLoc, targetLineLoc), 65 | ) 66 | state.breakpoints.splice(targetIndex, 1) 67 | }, 68 | setMessage: (state, action: PayloadAction) => { 69 | state.message = action.payload 70 | }, 71 | clearMessage: (state) => { 72 | state.message = null 73 | }, 74 | }, 75 | selectors: { 76 | selectEditorInput: (state) => state.input, 77 | selectEditorBreakpoints: (state) => state.breakpoints, 78 | selectEditorMessage: (state) => state.message, 79 | selectToPersist: createTypedStateSelector( 80 | [(state) => state.input, (state) => state.breakpoints], 81 | (input, breakpoints) => ({ input, breakpoints }), 82 | ), 83 | }, 84 | }) 85 | 86 | export const { 87 | setInput: setEditorInput, 88 | setBreakpoints, 89 | addBreakpoint, 90 | removeBreakpoint, 91 | setMessage: setEditorMessage, 92 | clearMessage: clearEditorMessage, 93 | } = editorSlice.actions 94 | 95 | export const { selectEditorInput, selectEditorBreakpoints, selectEditorMessage } = editorSlice.selectors 96 | -------------------------------------------------------------------------------- /src/features/editor/examples/hardware_interrupts.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Hardware Interrupts 3 | ; -------------------------------------- 4 | JMP Start ; Jump past the table of interrupt vectors 5 | DB 50 ; Vector at 02 pointing to address 50 6 | Start: 7 | STI ; Set I flag. Enable hardware interrupts 8 | MOV AL, 30 ; ASCII for '0' 9 | Loop: 10 | MOV [C0], AL ; Copy the data in AL to the video RAM 11 | INC AL ; Add one to AL 12 | JMP Loop 13 | ; -------------------------------------- 14 | ORG 50 15 | MOV AL, 30 ; Reset AL to ASCII for '0' 16 | IRET 17 | ; -------------------------------------- 18 | END 19 | ; -------------------------------------- 20 | 21 | For more examples, select File > Open Example. 22 | -------------------------------------------------------------------------------- /src/features/editor/examples/index.ts: -------------------------------------------------------------------------------- 1 | import hardwareInterrupts from './hardware_interrupts.asm?raw' 2 | import keyboardInput from './keyboard_input.asm?raw' 3 | import procedures from './procedures.asm?raw' 4 | import sevenSegmentDisplay from './seven_segment_display.asm?raw' 5 | import softwareInterrupts from './software_interrupts.asm?raw' 6 | import template from './template.asm?raw' 7 | import trafficLights from './traffic_lights.asm?raw' 8 | import visualDisplayUnit from './visual_display_unit.asm?raw' 9 | 10 | const TITLE_REGEXP = /;\t(.*)/ 11 | 12 | const getTitleFrom = (content: string): string => TITLE_REGEXP.exec(content)![1] 13 | 14 | interface Example { 15 | title: string 16 | content: string 17 | } 18 | 19 | const templateExample: Example = { 20 | title: getTitleFrom(template), 21 | content: template, 22 | } 23 | 24 | export { templateExample as template } 25 | 26 | export const isTemplate = (value: string) => value === templateExample.content 27 | 28 | export const templateSelection = (() => { 29 | const { title, content } = templateExample 30 | const titleIndex = content.indexOf(title) 31 | return { 32 | anchor: titleIndex, 33 | head: titleIndex + title.length, 34 | } 35 | })() 36 | 37 | export const examples: readonly Example[] = [ 38 | procedures, 39 | softwareInterrupts, 40 | hardwareInterrupts, 41 | keyboardInput, 42 | visualDisplayUnit, 43 | trafficLights, 44 | sevenSegmentDisplay, 45 | ].map((content) => { 46 | return { 47 | title: getTitleFrom(content), 48 | content, 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /src/features/editor/examples/keyboard_input.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Keyboard Input 3 | ; -------------------------------------- 4 | MOV BL, C0 ; Make BL point to video RAM 5 | Loop: 6 | IN 00 ; Wait for keyboard input 7 | CMP AL, 0D ; Check if the key was Enter (Carriage Return) 8 | JZ Done 9 | MOV [BL], AL ; Copy the data in AL to the video RAM that BL points to 10 | INC BL ; Make BL point to the next video RAM location 11 | JNZ Loop 12 | Done: 13 | ; -------------------------------------- 14 | END 15 | ; -------------------------------------- 16 | 17 | For more examples, select File > Open Example. 18 | -------------------------------------------------------------------------------- /src/features/editor/examples/procedures.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Procedures 3 | ; -------------------------------------- 4 | MOV AL, E0 ; 1110 0000 5 | Loop: 6 | NOT AL ; Invert the bits in AL 7 | OUT 01 ; Send data to traffic lights 8 | MOV BL, 01 ; Short delay 9 | CALL 30 ; Call procedure 30 10 | 11 | NOT AL ; Invert the bits in AL 12 | OUT 01 ; Send data to traffic lights 13 | MOV BL, 04 ; Middle sized delay 14 | CALL 30 ; Call procedure 30 15 | 16 | NOT AL ; Invert the bits in AL 17 | OUT 01 ; Send data to traffic lights 18 | MOV BL, 08 ; Longer delay 19 | CALL 30 ; Call procedure 30 20 | JMP Loop 21 | ; -------------------------------------- 22 | ORG 30 23 | Rep: 24 | DEC BL ; Subtract one from BL 25 | JNZ Rep 26 | RET 27 | ; -------------------------------------- 28 | END 29 | ; -------------------------------------- 30 | 31 | For more examples, select File > Open Example. 32 | -------------------------------------------------------------------------------- /src/features/editor/examples/seven_segment_display.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Seven-segment Display 3 | ; -------------------------------------- 4 | JMP Start ; Jump past the data table 5 | DB FA ; 0 - 1111 1010 6 | DB 60 ; 1 - 0110 0000 7 | DB B6 ; 2 - 1011 0110 8 | DB 9E ; 3 - 1001 1110 9 | DB 4E ; 4 - 0100 1110 10 | DB DC ; 5 - 1101 1100 11 | DB FC ; 6 - 1111 1100 12 | DB 8A ; 7 - 1000 1010 13 | DB FE ; 8 - 1111 1110 14 | DB DE ; 9 - 1101 1110 15 | DB 00 16 | Start: 17 | MOV BL, 02 ; Make BL point to the first entry in the data table 18 | MOV AL, [BL] ; Copy data from table to AL 19 | Loop: 20 | OUT 02 ; Send data to seven-segment display 21 | INC AL ; Set the right most bit to one 22 | NOP NOP NOP ; Wait for three cycles 23 | OUT 02 ; Send data to seven-segment display 24 | INC BL ; Make BL point to the next entry in the data table 25 | MOV AL, [BL] ; Copy data from table to AL 26 | CMP AL, 00 ; Check if the next entry exists 27 | JNZ Loop 28 | JMP Start 29 | ; -------------------------------------- 30 | END 31 | ; -------------------------------------- 32 | 33 | For more examples, select File > Open Example. 34 | -------------------------------------------------------------------------------- /src/features/editor/examples/software_interrupts.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Software Interrupts 3 | ; -------------------------------------- 4 | JMP Start ; Jump past the table of interrupt vectors 5 | DB 51 ; Vector at 02 pointing to address 51 6 | DB 71 ; Vector at 03 pointing to address 71 7 | Start: 8 | INT 02 ; Do interrupt 02 9 | INT 03 ; Do interrupt 03 10 | JMP Start 11 | ; -------------------------------------- 12 | ORG 50 13 | DB E0 14 | ; Interrupt code starts here 15 | MOV AL, [50] ; Copy bits from RAM into AL 16 | NOT AL ; Invert the bits in AL 17 | MOV [50], AL ; Copy inverted bits back to RAM 18 | OUT 01 ; Send data to traffic lights 19 | IRET 20 | ; -------------------------------------- 21 | ORG 70 22 | DB FE 23 | ; Interrupt code starts here 24 | MOV AL, [70] ; Copy bits from RAM into AL 25 | NOT AL ; Invert the bits in AL 26 | AND AL, FE ; Set the right most bit to zero 27 | MOV [70], AL ; Copy inverted bits back to RAM 28 | OUT 02 ; Send data to seven-segment display 29 | IRET 30 | ; -------------------------------------- 31 | END 32 | ; -------------------------------------- 33 | 34 | For more examples, select File > Open Example. 35 | -------------------------------------------------------------------------------- /src/features/editor/examples/template.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Untitled 3 | ; -------------------------------------- 4 | 5 | ; -------------------------------------- 6 | END 7 | ; -------------------------------------- 8 | -------------------------------------------------------------------------------- /src/features/editor/examples/traffic_lights.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Traffic Lights 3 | ; -------------------------------------- 4 | MOV AL, 80 ; 1000 0000 5 | Loop: 6 | OUT 01 ; Send data to traffic lights 7 | ROR AL ; Rotate the bits in AL to the right 8 | JMP Loop 9 | ; -------------------------------------- 10 | END 11 | ; -------------------------------------- 12 | 13 | For more examples, select File > Open Example. 14 | -------------------------------------------------------------------------------- /src/features/editor/examples/visual_display_unit.asm: -------------------------------------------------------------------------------- 1 | ; -------------------------------------- 2 | ; Visual Display Unit 3 | ; -------------------------------------- 4 | JMP Start ; Jump past the data table 5 | DB "Hello World!" 6 | DB 00 7 | Start: 8 | MOV AL, C0 ; Make AL point to video RAM 9 | MOV BL, 02 ; Make BL point to the first character in the string 10 | MOV CL, [BL] ; Copy the data from RAM into CL 11 | Loop: 12 | MOV [AL], CL ; Copy the data in CL to the video RAM that AL points to 13 | INC AL ; Make AL point to the next video RAM location 14 | INC BL ; Make BL point to the next character in the string 15 | MOV CL, [BL] ; Copy the data from RAM into CL 16 | CMP CL, 00 ; Check if the next character exists 17 | JNZ Loop 18 | ; -------------------------------------- 19 | END 20 | ; -------------------------------------- 21 | 22 | For more examples, select File > Open Example. 23 | -------------------------------------------------------------------------------- /src/features/editor/hooks.ts: -------------------------------------------------------------------------------- 1 | import { create } from '@codemirror-toolkit/react' 2 | import { useEffect } from 'react' 3 | import { debounceTime, filter, first, map, merge, switchMap } from 'rxjs' 4 | 5 | import { store, useSelector } from '@/app/store' 6 | import * as Maybe from '@/common/maybe' 7 | import { observe } from '@/common/observe' 8 | import { selectAssemblerError } from '@/features/assembler/assemblerSlice' 9 | import { resetCpuState, selectCpuFault, setCpuHalted } from '@/features/cpu/cpuSlice' 10 | 11 | import { setException } from '../exception/exceptionSlice' 12 | import { type ExceptionHandler, exceptionSink } from './codemirror/exceptionSink' 13 | import { setup } from './codemirror/setup' 14 | import { 15 | clearEditorMessage, 16 | type EditorMessage, 17 | MessageType, 18 | selectEditorInput, 19 | selectEditorMessage, 20 | setEditorMessage, 21 | } from './editorSlice' 22 | 23 | export const { useViewEffect, setContainer } = create((prevState) => { 24 | const editorInput = store.getState(selectEditorInput) 25 | const handleException: ExceptionHandler = (exception) => { 26 | store.dispatch(setException(exception)) 27 | } 28 | return { 29 | state: prevState, 30 | doc: editorInput, 31 | extensions: [setup(), exceptionSink(handleException)], 32 | } 33 | }) 34 | 35 | const MESSAGE_DURATION_MS = 2000 36 | 37 | const haltedMessage: EditorMessage = { 38 | type: MessageType.Info, 39 | content: 'Info: Program has halted.', 40 | } 41 | 42 | const errorToMessage = (error: Error): EditorMessage => { 43 | return { 44 | type: MessageType.Error, 45 | content: `${error.name}: ${error.message}`, 46 | } 47 | } 48 | 49 | export const useMessage = (): EditorMessage | null => { 50 | const assemblerError = useSelector(selectAssemblerError) 51 | const runtimeError = useSelector(selectCpuFault) 52 | 53 | const error = assemblerError ?? runtimeError 54 | 55 | const message = useSelector(selectEditorMessage) 56 | 57 | useEffect(() => { 58 | const message$ = store.onState(selectEditorMessage) 59 | const setCpuHalted$ = store.onAction(setCpuHalted) 60 | const resetCpuState$ = store.onAction(resetCpuState) 61 | return observe( 62 | merge( 63 | setCpuHalted$.pipe(map(() => setEditorMessage(haltedMessage))), 64 | resetCpuState$.pipe( 65 | switchMap(() => message$.pipe(first())), 66 | filter((msg) => msg === haltedMessage), 67 | map(() => clearEditorMessage()), 68 | ), 69 | ), 70 | (action) => store.dispatch(action), 71 | ) 72 | }, []) 73 | 74 | useEffect(() => { 75 | const setEditorMessage$ = store.onAction(setEditorMessage) 76 | return observe( 77 | setEditorMessage$.pipe( 78 | debounceTime(MESSAGE_DURATION_MS), 79 | filter(Boolean), 80 | filter(({ type }) => type !== MessageType.Error), 81 | map(() => clearEditorMessage()), 82 | ), 83 | (action) => store.dispatch(action), 84 | ) 85 | }, []) 86 | 87 | return Maybe.fromNullable(error).map(errorToMessage).orDefault(message) 88 | } 89 | -------------------------------------------------------------------------------- /src/features/editor/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from '@codemirror/view' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | 4 | import * as Maybe from '@/common/maybe' 5 | import { selectCurrentStatementRange } from '@/features/controller/selectors' 6 | 7 | export const selectCurrentStatementLinePos = createSelector( 8 | [selectCurrentStatementRange, (_, view: EditorView) => view.state.doc], 9 | (statementRange, doc) => 10 | Maybe.fromNullable(statementRange).chain((range) => { 11 | const linePos: number[] = [] 12 | const rangeTo = Math.min(range.to, doc.length) 13 | for (let pos = range.from; pos < rangeTo; pos++) { 14 | const line = doc.lineAt(pos) 15 | if (!linePos.includes(line.from)) { 16 | linePos.push(line.from) 17 | } 18 | } 19 | return Maybe.fromFalsy(linePos.length && linePos) 20 | }), 21 | ) 22 | -------------------------------------------------------------------------------- /src/features/exception/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, type FC, type PropsWithChildren, useCallback } from 'react' 2 | 3 | import { store } from '@/app/store' 4 | 5 | import { setException } from './exceptionSlice' 6 | 7 | type ErrorHandler = (error: Error) => void 8 | 9 | type ErrorBoundaryComponentProps = PropsWithChildren<{ 10 | onError: ErrorHandler 11 | }> 12 | 13 | class ErrorBoundaryComponent extends Component { 14 | public declare static displayName?: string 15 | 16 | public override componentDidCatch(error: Error) { 17 | this.props.onError(error) 18 | } 19 | 20 | public override render() { 21 | return this.props.children 22 | } 23 | } 24 | 25 | if (import.meta.env.DEV) { 26 | ErrorBoundaryComponent.displayName = 'ErrorBoundary' 27 | } 28 | 29 | const ErrorBoundary: FC = ({ children }) => { 30 | const handleError = useCallback((error) => { 31 | store.dispatch(setException(error)) 32 | }, []) 33 | 34 | return {children} 35 | } 36 | 37 | if (import.meta.env.DEV) { 38 | ErrorBoundary.displayName = 'ErrorBoundaryWrapper' 39 | } 40 | 41 | export default ErrorBoundary 42 | -------------------------------------------------------------------------------- /src/features/exception/ExceptionModal.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useCallback } from 'react' 2 | 3 | import { store, useSelector } from '@/app/store' 4 | import Anchor from '@/common/components/Anchor' 5 | import Modal from '@/common/components/Modal' 6 | import { useOutsideClick } from '@/common/hooks' 7 | 8 | import { clearException, selectException } from './exceptionSlice' 9 | 10 | const ExceptionModal: FC = () => { 11 | const error = useSelector(selectException) 12 | const hasError = error !== null 13 | 14 | const handleOutsideClick = useCallback(() => { 15 | store.dispatch(clearException()) 16 | }, []) 17 | const outsideClickRef = useOutsideClick(handleOutsideClick) 18 | 19 | return ( 20 | 21 |
24 | {hasError 25 | ? error.stack 26 | ?.split(/\n(.*)/s) 27 | .slice(0, -1) // remove empty string 28 | .map((line, lineIndex) => { 29 | const isErrorMessage = lineIndex === 0 30 | return isErrorMessage 31 | ? ( 32 |
33 | {line.split(': ').map((messagePart, messagePartIndex) => { 34 | const isErrorName = messagePartIndex === 0 35 | return ( 36 |
41 | {messagePart} 42 |
43 | ) 44 | })} 45 |
46 | ) 47 | : ( 48 |
51 | {line} 52 |
53 | ) 54 | }) 55 | : null} 56 |
57 | Please report this bug at{' '} 58 | 59 | https://github.com/exuanbo/assembler-simulator/issues 60 | 61 | . 62 |
63 |
64 |
65 | ) 66 | } 67 | 68 | export default ExceptionModal 69 | -------------------------------------------------------------------------------- /src/features/exception/exceptionSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { type ErrorObject, errorToPlainObject } from '@/common/error' 4 | 5 | type ExceptionState = ErrorObject | null 6 | 7 | const initialState = null as ExceptionState 8 | 9 | export const exceptionSlice = createSlice({ 10 | name: 'exception', 11 | initialState, 12 | reducers: { 13 | set: { 14 | reducer: (_, action: PayloadAction) => action.payload, 15 | prepare: (exception: unknown) => { 16 | if (exception instanceof Error) { 17 | return { payload: errorToPlainObject(exception) } 18 | } 19 | else { 20 | const error = new Error(`Uncaught ${JSON.stringify(exception)}`) 21 | return { payload: errorToPlainObject(error) } 22 | } 23 | }, 24 | }, 25 | clear: () => null, 26 | }, 27 | selectors: { 28 | selectException: (state) => state, 29 | }, 30 | }) 31 | 32 | export const { set: setException, clear: clearException } = exceptionSlice.actions 33 | 34 | export const { selectException } = exceptionSlice.selectors 35 | -------------------------------------------------------------------------------- /src/features/exception/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { store } from '@/app/store' 4 | 5 | import { setException } from './exceptionSlice' 6 | 7 | export const useGlobalExceptionHandler = (): void => { 8 | useEffect(() => { 9 | const handleError = (event: ErrorEvent): void => { 10 | store.dispatch(setException(event.error)) 11 | } 12 | const handlePromiseRejection = (event: PromiseRejectionEvent): void => { 13 | store.dispatch(setException(event.reason)) 14 | } 15 | window.addEventListener('error', handleError) 16 | window.addEventListener('unhandledrejection', handlePromiseRejection) 17 | return () => { 18 | window.removeEventListener('error', handleError) 19 | window.removeEventListener('unhandledrejection', handlePromiseRejection) 20 | } 21 | }, []) 22 | } 23 | -------------------------------------------------------------------------------- /src/features/io/DeviceCard.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | 3 | import CardHeader from '@/common/components/CardHeader' 4 | import { Close } from '@/common/components/icons' 5 | import { classNames } from '@/common/utils' 6 | 7 | type Props = PropsWithChildren<{ 8 | name: string 9 | onClose?: () => void 10 | className?: string 11 | }> 12 | 13 | const DeviceCard: FC = ({ name, onClose, className, children }) => { 14 | const isClosable = onClose !== undefined 15 | return ( 16 |
17 | 18 | {isClosable && ( 19 | 22 | 23 | 24 | )} 25 | 26 |
{children}
27 |
28 | ) 29 | } 30 | 31 | export default DeviceCard 32 | -------------------------------------------------------------------------------- /src/features/io/IoDevices.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import CardHeader from '@/common/components/CardHeader' 4 | import { ArrowDown, ArrowUp } from '@/common/components/icons' 5 | import { useToggle } from '@/common/hooks' 6 | 7 | import SevenSegmentDisplay from './SevenSegmentDisplay' 8 | import SimulatedKeyboard from './SimulatedKeyboard' 9 | import TrafficLights from './TrafficLights' 10 | import VisualDisplayUnit from './VisualDisplayUnit' 11 | 12 | const IoDevices: FC = () => { 13 | const [isOpen, toggleOpen] = useToggle(true) 14 | const Icon = isOpen ? ArrowUp : ArrowDown 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | {isOpen && ( 25 |
26 | 27 | 28 | 29 |
30 | )} 31 |
32 | ) 33 | } 34 | 35 | export default IoDevices 36 | -------------------------------------------------------------------------------- /src/features/io/SimulatedKeyboard.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useEffect, useRef, useState } from 'react' 2 | 3 | import { store, useSelector } from '@/app/store' 4 | import Modal from '@/common/components/Modal' 5 | import { Ascii } from '@/common/constants' 6 | import { classNames, range } from '@/common/utils' 7 | import { selectIsSuspended, setSuspended } from '@/features/controller/controllerSlice' 8 | 9 | import { InputPort, SKIP } from './core' 10 | import { 11 | selectIsWaitingForKeyboardInput, 12 | setInputData, 13 | setWaitingForKeyboardInput, 14 | } from './ioSlice' 15 | 16 | const MAX_DOT_COUNT = 3 17 | const PULSE_INTERVAL_MS = 250 18 | 19 | const PulseLoader: FC = () => { 20 | const [count, setCount] = useState(1) 21 | 22 | useEffect(() => { 23 | const intervalId = window.setInterval( 24 | () => setCount((prevCount) => (prevCount + 1) % (MAX_DOT_COUNT + 1)), 25 | PULSE_INTERVAL_MS, 26 | ) 27 | return () => window.clearInterval(intervalId) 28 | }, []) 29 | 30 | return ( 31 | 32 | {range(MAX_DOT_COUNT).map((index) => ( 33 | = count })}> 34 | . 35 | 36 | ))} 37 | 38 | ) 39 | } 40 | 41 | const SimulatedKeyboard: FC = () => { 42 | const isSuspended = useSelector(selectIsSuspended) 43 | const isWaitingForKeyboardInput = useSelector(selectIsWaitingForKeyboardInput) 44 | const shouldOpen = isSuspended && isWaitingForKeyboardInput 45 | 46 | const inputRef = useRef(null) 47 | 48 | const focusInput = (): void => { 49 | // focus immediately `onBlur` does not work in Firefox 50 | // https://stackoverflow.com/a/15670691/13346012 51 | window.setTimeout(() => { 52 | inputRef.current?.focus() 53 | }) 54 | } 55 | 56 | const dispatchInputData = (content: number): void => { 57 | store.dispatch( 58 | setInputData({ 59 | content, 60 | port: InputPort.SimulatedKeyboard, 61 | }), 62 | ) 63 | store.dispatch(setSuspended(false)) 64 | store.dispatch(setWaitingForKeyboardInput(false)) 65 | } 66 | 67 | const handleKeyDown: React.KeyboardEventHandler = (event) => { 68 | // handle special keys 69 | switch (event.key) { 70 | case 'Backspace': 71 | dispatchInputData(Ascii.BS) 72 | break 73 | case 'Tab': 74 | event.preventDefault() 75 | dispatchInputData(Ascii.TAB) 76 | break 77 | case 'Enter': 78 | dispatchInputData(Ascii.CR) 79 | break 80 | case 'Escape': 81 | dispatchInputData(SKIP) 82 | break 83 | } 84 | } 85 | 86 | const handleInputChange: React.ChangeEventHandler = (event) => { 87 | const input = event.target.value 88 | dispatchInputData(input.charCodeAt(0)) 89 | } 90 | 91 | return ( 92 | 95 |
96 | Waiting for keyboard input 97 |
98 | 106 |
107 | ) 108 | } 109 | 110 | export default SimulatedKeyboard 111 | -------------------------------------------------------------------------------- /src/features/io/VisualDisplayUnit.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useEffect } from 'react' 2 | import { first, map, merge, switchMap } from 'rxjs' 3 | 4 | import { store } from '@/app/store' 5 | import { NO_BREAK_SPACE } from '@/common/constants' 6 | import { observe } from '@/common/observe' 7 | import { asciiToChars, chunk } from '@/common/utils' 8 | import { 9 | initMemoryDataFrom, 10 | resetMemoryData, 11 | selectMemoryData, 12 | } from '@/features/memory/memorySlice' 13 | 14 | import DeviceCard from './DeviceCard' 15 | import { useIoDevice } from './hooks' 16 | import { IoDeviceName, setVduDataFrom } from './ioSlice' 17 | 18 | const VisualDisplayUnit: FC = () => { 19 | const { data, isVisible, toggleVisible } = useIoDevice(IoDeviceName.VisualDisplayUnit) 20 | 21 | useEffect(() => { 22 | // TODO: refactor handling memory changes 23 | const initMemoryDataFrom$ = store.onAction(initMemoryDataFrom) 24 | const resetMemoryData$ = store.onAction(resetMemoryData) 25 | const memoryData$ = store.onState(selectMemoryData) 26 | return observe( 27 | merge(initMemoryDataFrom$, resetMemoryData$).pipe( 28 | switchMap(() => memoryData$.pipe(first())), 29 | map(setVduDataFrom), 30 | ), 31 | (action) => store.dispatch(action), 32 | ) 33 | }, []) 34 | 35 | if (!isVisible) { 36 | return null 37 | } 38 | 39 | return ( 40 | 44 | {chunk(0x10, asciiToChars(data)).map((row, rowIndex) => ( 45 |
46 | {row.map((char, charIndex) => ( 47 |
48 | {char === ' ' ? NO_BREAK_SPACE : char} 49 |
50 | ))} 51 |
52 | ))} 53 |
54 | ) 55 | } 56 | 57 | export default VisualDisplayUnit 58 | -------------------------------------------------------------------------------- /src/features/io/core.ts: -------------------------------------------------------------------------------- 1 | export const SKIP = -1 2 | 3 | export const MAX_PORT = 0x0f 4 | 5 | export enum InputPort { 6 | SimulatedKeyboard = 0, 7 | Thermostat = 3, 8 | Keyboard = 7, 9 | NumericKeypad = 8, 10 | } 11 | 12 | export type InputData = 13 | | { 14 | content: number 15 | port: InputPort 16 | } 17 | | { 18 | content: null 19 | port: null 20 | } 21 | 22 | export interface InputSignals { 23 | data: InputData 24 | interrupt: boolean 25 | // TODO: interruptVectorAddress 26 | } 27 | 28 | export const initialInputSignals: InputSignals = { 29 | data: { 30 | content: null, 31 | port: null, 32 | }, 33 | interrupt: false, 34 | } 35 | 36 | export enum OutputPort { 37 | TrafficLights = 1, 38 | SevenSegmentDisplay = 2, 39 | Heater = 3, 40 | SnakeInMaze = 4, 41 | StepperMotor = 5, 42 | Lift = 6, 43 | Keyboard = 7, 44 | NumericKeypad = 8, 45 | } 46 | 47 | type OutputFlagSignalName = 'halted' | 'closeWindows' 48 | 49 | type OutputFlagSignals = { 50 | [signalName in OutputFlagSignalName]?: true 51 | } 52 | 53 | interface OutputData { 54 | content: number 55 | port: OutputPort 56 | } 57 | 58 | export interface OutputSignals extends OutputFlagSignals { 59 | expectedInputPort?: InputPort 60 | data?: OutputData 61 | } 62 | 63 | export interface Signals { 64 | input: InputSignals 65 | output: OutputSignals 66 | } 67 | -------------------------------------------------------------------------------- /src/features/io/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'react' 2 | import { filter } from 'rxjs' 3 | 4 | import { store, useSelector } from '@/app/store' 5 | import { observe, type Unsubscribe } from '@/common/observe' 6 | import { curryRight2 } from '@/common/utils' 7 | 8 | import { 9 | type IoDeviceData, 10 | type IoDeviceName, 11 | type IoDeviceState, 12 | selectIoDeviceData, 13 | selectIoDeviceVisibility, 14 | setIoDeviceData, 15 | toggleIoDeviceVisible, 16 | } from './ioSlice' 17 | 18 | type DataCallback = (data: IoDeviceData) => void 19 | 20 | interface IoDeviceActions { 21 | subscribeData: (callback: DataCallback) => Unsubscribe 22 | toggleVisible: () => void 23 | } 24 | 25 | interface IoDevice extends IoDeviceState, IoDeviceActions {} 26 | 27 | export const useIoDevice = (deviceName: IoDeviceName): IoDevice => { 28 | const selectData = useMemo(() => curryRight2(selectIoDeviceData)(deviceName), [deviceName]) 29 | const data = useSelector(selectData) 30 | 31 | const subscribeData = useCallback( 32 | (listener: DataCallback) => { 33 | const data$ = store.onState(selectData) 34 | return observe(data$, listener) 35 | }, 36 | [selectData], 37 | ) 38 | 39 | const isVisible = useSelector( 40 | useMemo(() => curryRight2(selectIoDeviceVisibility)(deviceName), [deviceName]), 41 | ) 42 | 43 | const toggleVisible = useCallback(() => { 44 | store.dispatch(toggleIoDeviceVisible(deviceName)) 45 | }, [deviceName]) 46 | 47 | useEffect(() => { 48 | if (isVisible) { 49 | return 50 | } 51 | const setIoDeviceData$ = store.onAction(setIoDeviceData) 52 | return observe(setIoDeviceData$.pipe(filter(({ name }) => name === deviceName)), toggleVisible) 53 | }, [deviceName, isVisible, toggleVisible]) 54 | 55 | return { data, subscribeData, isVisible, toggleVisible } 56 | } 57 | -------------------------------------------------------------------------------- /src/features/memory/Memory.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { useSelector } from '@/app/store' 4 | import CardHeader from '@/common/components/CardHeader' 5 | import { ArrowDown, ArrowUp } from '@/common/components/icons' 6 | import { useToggle } from '@/common/hooks' 7 | import { classNames, decToHex, invariant, range } from '@/common/utils' 8 | import { MAX_SP } from '@/features/cpu/core' 9 | import { selectCpuPointerRegisters } from '@/features/cpu/cpuSlice' 10 | 11 | import { MemoryView, selectMemoryDataRows, selectMemoryView } from './memorySlice' 12 | import { selectMemorySourceRows } from './selectors' 13 | 14 | const Memory: FC = () => { 15 | const [isOpen, toggleOpen] = useToggle(true) 16 | const Icon = isOpen ? ArrowUp : ArrowDown 17 | 18 | const memoryView = useSelector(selectMemoryView) 19 | const isDataView = memoryView !== MemoryView.Source 20 | 21 | const getDataRows = useSelector(selectMemoryDataRows) 22 | const getSourceRows = useSelector(selectMemorySourceRows) 23 | 24 | const rows = isDataView ? getDataRows() : getSourceRows() 25 | 26 | const { ip, sp } = useSelector(selectCpuPointerRegisters) 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | 34 | 35 | {isOpen && ( 36 | 37 | 38 | 39 | 44 | ))} 45 | 46 | {rows.map((row, rowIndex) => ( 47 | 48 | 51 | {row.map((value, colIndex) => { 52 | const address = rowIndex * 0x10 + colIndex 53 | return ( 54 | 69 | ) 70 | })} 71 | 72 | ))} 73 | 74 |
40 | {range(0x10).map((colIndex) => ( 41 | 42 | {decToHex(colIndex)[1] /* ignore padded 0 */} 43 |
49 | {decToHex(rowIndex)[1] /* ignore padded 0 */} 50 | 55 | sp && address <= MAX_SP, 62 | }, 63 | )}> 64 | {memoryView === MemoryView.Hexadecimal 65 | ? (invariant(typeof value === 'number'), decToHex(value)) 66 | : value} 67 | 68 |
75 | )} 76 |
77 | ) 78 | } 79 | 80 | export default Memory 81 | -------------------------------------------------------------------------------- /src/features/memory/core.ts: -------------------------------------------------------------------------------- 1 | import { Ascii, Mnemonic } from '@/common/constants' 2 | import type { AddressToCodeMap, AddressToStatementMap } from '@/features/assembler/core' 3 | 4 | export type MemoryData = number[] 5 | 6 | const MEMORY_SIZE = 0x100 7 | 8 | export const VDU_START_ADDRESS = 0xc0 9 | 10 | export const initVduData = (): number[] => 11 | new Array(MEMORY_SIZE - VDU_START_ADDRESS).fill(Ascii.Space) 12 | 13 | export const initData = (): MemoryData => 14 | new Array(VDU_START_ADDRESS).fill(0).concat(initVduData()) 15 | 16 | export const initDataFrom = (map: AddressToCodeMap): MemoryData => { 17 | const data = initData() 18 | for (const address in map) { 19 | data[address] = map[address] 20 | } 21 | return data 22 | } 23 | 24 | export const getVduDataFrom = (data: MemoryData): number[] => data.slice(VDU_START_ADDRESS) 25 | 26 | export const getSourceFrom = (map: Partial): string[] => { 27 | const source: string[] = [] 28 | for (let address = 0; address < MEMORY_SIZE; address++) { 29 | source.push(address < VDU_START_ADDRESS ? Mnemonic.END : '') 30 | } 31 | for (const address in map) { 32 | const statement = map[address] 33 | // istanbul ignore next 34 | if (statement === undefined) { 35 | continue 36 | } 37 | const { instruction, operands } = statement 38 | if (instruction.mnemonic === Mnemonic.DB) { 39 | const operand = operands[0] 40 | // OperandType.Number 41 | if (typeof operand.code === 'number') { 42 | source[address] = operand.value 43 | } 44 | else { 45 | // OperandType.String 46 | operand.value.split('').forEach((char, charIndex) => { 47 | source[Number(address) + charIndex] = char 48 | }) 49 | } 50 | } 51 | else { 52 | source[address] = instruction.mnemonic 53 | const nextAddress = Number(address) + 1 54 | operands.forEach((operand, operandIndex) => { 55 | const { value } = operand 56 | // Address or RegisterAddress 57 | const isAddressOperand = operand.type.endsWith('Address') 58 | source[nextAddress + operandIndex] = isAddressOperand ? `[${value}]` : value 59 | }) 60 | } 61 | } 62 | return source 63 | } 64 | -------------------------------------------------------------------------------- /src/features/memory/memorySlice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { chunk } from '@/common/utils' 4 | import type { AddressToCodeMap } from '@/features/assembler/core' 5 | 6 | import { initData, initDataFrom, type MemoryData } from './core' 7 | 8 | export enum MemoryView { 9 | Hexadecimal = 'Hexadecimal', 10 | Decimal = 'Decimal', 11 | Source = 'Source', 12 | } 13 | 14 | export const memoryViewOptions: readonly MemoryView[] = Object.values(MemoryView) 15 | 16 | interface MemoryState { 17 | data: MemoryData 18 | view: MemoryView 19 | } 20 | 21 | const initialData = initData() 22 | 23 | const initialState: MemoryState = { 24 | data: initialData, 25 | view: MemoryView.Hexadecimal, 26 | } 27 | 28 | const createTypedStateSelector = createSelector.withTypes() 29 | 30 | export const memorySlice = createSlice({ 31 | name: 'memory', 32 | initialState, 33 | reducers: { 34 | initDataFrom: (state, action: PayloadAction) => { 35 | state.data = initDataFrom(action.payload) 36 | }, 37 | setData: (state, action: PayloadAction) => { 38 | state.data = action.payload 39 | }, 40 | resetData: (state) => { 41 | state.data = initialData 42 | }, 43 | setView: (state, action: PayloadAction) => { 44 | state.view = action.payload 45 | }, 46 | }, 47 | selectors: { 48 | selectMemoryData: (state) => state.data, 49 | selectMemoryDataRows: createTypedStateSelector( 50 | [(state) => state.data], 51 | (memoryData) => () => chunk(0x10, memoryData), 52 | ), 53 | selectMemoryView: (state) => state.view, 54 | }, 55 | }) 56 | 57 | export const { 58 | initDataFrom: initMemoryDataFrom, 59 | setData: setMemoryData, 60 | resetData: resetMemoryData, 61 | setView: setMemoryView, 62 | } = memorySlice.actions 63 | 64 | export const { selectMemoryData, selectMemoryDataRows, selectMemoryView } = memorySlice.selectors 65 | -------------------------------------------------------------------------------- /src/features/memory/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | 3 | import { chunk } from '@/common/utils' 4 | import { selectAddressToStatementMap } from '@/features/assembler/assemblerSlice' 5 | 6 | import { getSourceFrom } from './core' 7 | 8 | export const selectMemorySourceRows = createSelector( 9 | selectAddressToStatementMap, 10 | (addressToStatementMap) => () => { 11 | const memorySource = getSourceFrom(addressToStatementMap) 12 | return chunk(0x10, memorySource) 13 | }, 14 | ) 15 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@unocss/reset/tailwind-compat.css' 2 | import 'virtual:uno.css' 3 | import './styles.css' 4 | 5 | import * as React from 'react' 6 | import * as ReactDOM from 'react-dom/client' 7 | 8 | import App from './app/App' 9 | 10 | const appContainer = document.getElementById('app-root')! 11 | const root = ReactDOM.createRoot(appContainer) 12 | 13 | root.render( 14 | 15 | 16 | , 17 | ) 18 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | @apply cursor-default; 3 | @apply font-mono; 4 | @apply font-ligatures-none; 5 | @apply select-none; 6 | @apply overscroll-none; 7 | } 8 | -------------------------------------------------------------------------------- /src/ts-reset.d.ts: -------------------------------------------------------------------------------- 1 | // TODO: move to separate file 2 | type WithRequired = T & Omit & Required> 3 | 4 | interface ObjectConstructor { 5 | hasOwn, K extends PropertyKey>(o: T, v: K): 6 | v is keyof T 7 | 8 | hasOwn(o: T, v: K): 9 | o is K extends keyof T 10 | ? WithRequired 11 | : Extract> 12 | } 13 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | interface ImportMetaEnv { 5 | readonly NEVER: false 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | 12 | declare const __VERSION__: string 13 | declare const __COMMIT_HASH__: string 14 | declare const __COMMIT_DATE__: string 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Type Checking 4 | "noImplicitOverride": true, 5 | "strict": true, 6 | 7 | // Modules 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | "resolveJsonModule": true, 14 | 15 | // Emit 16 | "noEmit": true, 17 | 18 | // Interop Constraints 19 | "allowSyntheticDefaultImports": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "isolatedModules": true, 22 | "verbatimModuleSyntax": true, 23 | 24 | // Language and Environment 25 | "jsx": "react-jsx", 26 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 27 | "target": "ESNext", 28 | "useDefineForClassFields": true, 29 | 30 | // Output Formatting 31 | "noErrorTruncation": true, 32 | 33 | // Completeness 34 | "skipLibCheck": true 35 | }, 36 | "include": ["."] 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | // Modules 5 | "moduleResolution": "Node10", 6 | 7 | // Interop Constraints 8 | "verbatimModuleSyntax": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import presetWebFonts from '@unocss/preset-web-fonts' 2 | import presetWind from '@unocss/preset-wind' 3 | import transformerDirectives from '@unocss/transformer-directives' 4 | import transformerVariantGroup from '@unocss/transformer-variant-group' 5 | import { defineConfig } from 'unocss' 6 | 7 | // TODO: self-host fonts 8 | 9 | export default defineConfig({ 10 | blocklist: ['?', 'px', 'static'], 11 | presets: [ 12 | presetWind(), 13 | presetWebFonts({ 14 | provider: 'bunny', 15 | fonts: { 16 | mono: { 17 | name: 'JetBrains Mono', 18 | weights: [400, 700], 19 | }, 20 | }, 21 | }), 22 | ], 23 | rules: [ 24 | [ 25 | 'font-ligatures-none', 26 | { 27 | 'font-variant-ligatures': 'none', 28 | 'font-feature-settings': "'liga' 0", 29 | }, 30 | ], 31 | ], 32 | transformers: [transformerVariantGroup(), transformerDirectives()], 33 | }) 34 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from 'node:child_process' 2 | import { join } from 'node:path' 3 | import { promisify } from 'node:util' 4 | 5 | import react from '@vitejs/plugin-react' 6 | import unocss from 'unocss/vite' 7 | import { defineConfig, mergeConfig, type UserConfig } from 'vite' 8 | import { VitePWA as pwa } from 'vite-plugin-pwa' 9 | 10 | import { description, name, version } from './package.json' 11 | import split from './scripts/splitVendorChunk' 12 | 13 | export const baseConfig = defineConfig({ 14 | base: './', 15 | plugins: [ 16 | unocss(), 17 | react(), 18 | pwa({ 19 | manifestFilename: 'app.webmanifest', 20 | registerType: 'prompt', 21 | includeAssets: ['favicon.ico', 'apple-touch-icon.png'], 22 | manifest: { 23 | name: 'Assembler Simulator', 24 | short_name: 'AssemblerSimulator', 25 | description, 26 | id: `/${name}/`, 27 | theme_color: '#ffffff', 28 | icons: [ 29 | { 30 | src: 'pwa-192x192.png', 31 | sizes: '192x192', 32 | type: 'image/png', 33 | }, 34 | { 35 | src: 'pwa-512x512.png', 36 | sizes: '512x512', 37 | type: 'image/png', 38 | }, 39 | ], 40 | }, 41 | }), 42 | ], 43 | resolve: { 44 | alias: { 45 | '@': join(__dirname, 'src'), 46 | }, 47 | }, 48 | }) 49 | 50 | export default defineConfig(async (env) => { 51 | const config = env.command === 'serve' 52 | ? await getServeConfig() 53 | : await getBuildConfig() 54 | return mergeConfig(baseConfig, config) 55 | }) 56 | 57 | async function getServeConfig(): Promise { 58 | return { 59 | define: await getGlobalReplacements(), 60 | server: { 61 | watch: { 62 | ignored: [/coverage/], 63 | }, 64 | }, 65 | } 66 | } 67 | 68 | async function getBuildConfig(): Promise { 69 | return { 70 | define: await getGlobalReplacements(), 71 | plugins: [split()], 72 | } 73 | } 74 | 75 | async function getGlobalReplacements() { 76 | const exec = promisify(child_process.exec) 77 | 78 | async function getCommitHash() { 79 | const { stdout } = await exec('git rev-parse --short HEAD') 80 | return stdout.trimEnd() 81 | } 82 | 83 | async function getCommitDate() { 84 | const { stdout } = await exec('git log -1 --format=%cd') 85 | return new Date(stdout).toISOString() 86 | } 87 | 88 | return forkJoin({ 89 | __VERSION__: JSON.stringify(version), 90 | __COMMIT_HASH__: getCommitHash().then(JSON.stringify), 91 | __COMMIT_DATE__: getCommitDate().then(JSON.stringify), 92 | }) 93 | } 94 | 95 | type Joined = { 96 | [K in keyof T]: Awaited 97 | } 98 | 99 | async function forkJoin>(promises: T) { 100 | const keys = Object.keys(promises) 101 | const values = await Promise.all(Object.values(promises)) 102 | return >Object.fromEntries(keys.map((key, index) => [key, values[index]])) 103 | } 104 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vitest/config' 2 | 3 | import { baseConfig } from './vite.config' 4 | 5 | export default mergeConfig(baseConfig, { 6 | test: { 7 | include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], 8 | coverage: { 9 | all: false, 10 | }, 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------