Web-Ide mit aufgenommen

This commit is contained in:
Riwoldt
2026-04-09 14:14:56 +02:00
parent 64816c45cc
commit 15cfaf332d
489 changed files with 186891 additions and 0 deletions
@@ -0,0 +1,490 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import {
Err,
isErr,
Ok,
Result,
} from "@davidsouther/jiffies/lib/esm/result.js";
import {
CompareResultLengths,
CompareResultLine,
compareLines,
} from "@nand2tetris/simulator/compare.js";
import {
KEYBOARD_OFFSET,
SCREEN_OFFSET,
} from "@nand2tetris/simulator/cpu/memory.js";
import {
ASM,
Asm,
fillLabel,
isAValueInstruction,
translateInstruction,
} from "@nand2tetris/simulator/languages/asm.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { bin } from "@nand2tetris/simulator/util/twos.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { RunSpeed } from "src/runbar.js";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export interface TranslatorSymbol {
name: string;
value: string;
}
function defaultSymbols(): TranslatorSymbol[] {
return [
{ name: "R0", value: "0" },
{ name: "R1", value: "1" },
{ name: "R2", value: "2" },
{ name: "...", value: "" }, // abbreviation of R3 - R14
{ name: "R15", value: "15" },
{ name: "SCREEN", value: SCREEN_OFFSET.toString() },
{ name: "KBD", value: KEYBOARD_OFFSET.toString() },
];
}
interface HighlightInfo {
resultHighlight: Span | undefined;
sourceHighlight: Span | undefined;
highlightMap: Map<Span, Span>;
}
interface AsmVariable {
name: string;
isHidden: boolean;
}
class Translator {
asm: Asm = { instructions: [] };
current = -1;
done = false;
symbols: TranslatorSymbol[] = [];
private variables: Map<number, AsmVariable> = new Map();
private lines: string[] = [];
lineNumbers: number[] = [];
getResult() {
return this.lines.join("\n");
}
load(asm: Asm, lineNum: number): Result<void, CompilationError> {
this.symbols = defaultSymbols();
this.variables.clear();
this.asm = asm;
const result = fillLabel(asm, (name, value, isVar) => {
if (isVar) {
this.variables.set(value, { name: name, isHidden: true });
} else {
this.symbols.push({ name: name, value: value.toString() });
}
});
if (isErr(result)) {
return result;
}
asm.instructions = asm.instructions.filter(({ type }) => type !== "L");
this.resolveLineNumbers(lineNum);
this.reset();
return Ok();
}
resolveLineNumbers(lineNum: number) {
this.lineNumbers = Array(lineNum);
let currentLine = 0;
for (const instruction of this.asm.instructions) {
if (
(instruction.type === "A" || instruction.type === "C") &&
instruction.span != undefined
) {
this.lineNumbers[instruction.span.line] = currentLine;
currentLine += 1;
}
}
}
step(highlightInfo: HighlightInfo) {
if (this.current >= this.asm.instructions.length - 1) {
return;
}
this.current += 1;
const instruction = this.asm.instructions[this.current];
if (instruction.type === "A" || instruction.type === "C") {
highlightInfo.sourceHighlight = instruction.span;
const result = translateInstruction(this.asm.instructions[this.current]);
if (result === undefined) {
return;
}
this.lines.push(`${bin(result)}`);
highlightInfo.resultHighlight = {
start: this.current * 17,
end: (this.current + 1) * 17,
line: -1,
};
if (highlightInfo.sourceHighlight) {
highlightInfo.highlightMap.set(
highlightInfo.sourceHighlight,
highlightInfo.resultHighlight,
);
}
if (isAValueInstruction(instruction)) {
const variable = this.variables.get(instruction.value);
if (variable != undefined && variable.isHidden) {
this.symbols.push({
name: variable.name,
value: instruction.value.toString(),
});
variable.isHidden = false;
}
}
if (this.current === this.asm.instructions.length - 1) {
this.done = true;
}
}
}
resetSymbols() {
for (const variable of this.variables.values()) {
variable.isHidden = true;
}
const variableNames = new Set(
Array.from(this.variables.values()).map((v) => v.name),
);
this.symbols = this.symbols.filter(
(symbol) => !variableNames.has(symbol.name),
);
}
reset() {
this.current = -1;
this.lines = [];
this.done = false;
this.resetSymbols();
}
}
export interface AsmPageState {
asm: string;
path: string | undefined;
translating: boolean;
current: number;
resultHighlight: Span | undefined;
sourceHighlight: Span | undefined;
symbols: TranslatorSymbol[];
result: string;
compare: string;
compareName: string | undefined;
lineNumbers: number[];
error?: CompilationError;
compareError: boolean;
title?: string;
config: AsmPageConfig;
}
export interface AsmPageConfig {
speed: RunSpeed;
}
export type AsmStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeAsmStore>["reducers"];
payload?: unknown;
}>;
export function makeAsmStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
dispatch: MutableRefObject<AsmStoreDispatch>,
upgraded: boolean,
) {
const translator = new Translator();
const highlightInfo: HighlightInfo = {
resultHighlight: undefined,
sourceHighlight: undefined,
highlightMap: new Map(),
};
let path: string | undefined;
let animate = true;
let compiled = false;
let translating = false;
let failure = false;
const reducers = {
setAsm(
state: AsmPageState,
{ asm, path }: { asm: string; path: string | undefined },
) {
state.asm = asm;
if (path) {
state.path = path;
}
},
setCmp(state: AsmPageState, { cmp, name }: { cmp: string; name: string }) {
state.compare = cmp;
state.compareName = name;
setStatus("Loaded compare file");
},
setError(state: AsmPageState, error?: CompilationError) {
if (error) {
setStatus({
message: error.message,
severity: "ERROR",
});
}
state.error = error;
},
update(state: AsmPageState) {
state.translating = translating;
state.current = translator.current;
state.result = translator.getResult();
state.symbols = Array.from(translator.symbols);
state.lineNumbers = Array.from(translator.lineNumbers);
state.sourceHighlight = highlightInfo.sourceHighlight;
state.resultHighlight = highlightInfo.resultHighlight;
state.compareError = failure;
},
compare(state: AsmPageState) {
const comparison = compareLines(state.result, state.compare);
if ((comparison as CompareResultLengths).lenA) {
failure = true;
setStatus({
message: "Comparison failed - different lengths",
severity: "ERROR",
});
return;
}
const { line } = comparison as CompareResultLine;
if (line) {
setStatus({
message: `Comparison failure: Line ${line}`,
severity: "ERROR",
});
failure = true;
highlightInfo.resultHighlight = {
start: line * 17,
end: (line + 1) * 17,
line: -1,
};
return;
}
setStatus({
message: "Comparison successful",
severity: "SUCCESS",
});
},
setTitle(state: AsmPageState, title: string) {
state.title = title;
},
updateConfig(state: AsmPageState, config: Partial<AsmPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const actions = {
async loadAsm(_path: string) {
path = _path;
const source = await fs.readFile(path);
actions.setAsm(source, path);
},
setAsm(asm: string, path?: string) {
asm = asm.replace(/\r\n/g, "\n");
dispatch.current({
action: "setAsm",
payload: { asm, path },
});
translating = false;
this.saveAsm(asm);
requestAnimationFrame(() => {
this.compileAsm(asm);
});
},
saveAsm(asm: string) {
if (path) {
fs.writeFile(path, asm);
}
},
compileAsm(asm: string) {
this.reset();
const parseResult = ASM.parse(asm);
if (isErr(parseResult)) {
dispatch.current({
action: "setError",
payload: Err(parseResult),
});
compiled = false;
return;
}
const loadResult = translator.load(
Ok(parseResult),
asm.split("\n").length,
);
if (isErr(loadResult)) {
dispatch.current({
action: "setError",
payload: Err(loadResult),
});
compiled = false;
return;
}
compiled = translator.asm.instructions.length > 0;
setStatus("");
dispatch.current({ action: "setError" });
dispatch.current({ action: "update" });
},
setAnimate(value: boolean) {
animate = value;
},
async step(): Promise<boolean> {
if (compiled) {
translating = true;
}
translator.step(highlightInfo);
if (animate || translator.done) {
dispatch.current({ action: "update" });
if (path && upgraded) {
await fs.writeFile(
path.replace(/\.asm$/, ".hack"),
translator.getResult(),
);
}
}
if (translator.done) {
setStatus({
message: "Translation done.",
severity: "SUCCESS",
});
}
return translator.done;
},
compare() {
dispatch.current({ action: "compare" });
this.updateHighlight(highlightInfo.resultHighlight?.start ?? 0, false);
dispatch.current({ action: "update" });
},
updateHighlight(index: number, fromSource: boolean) {
if (failure) {
return;
}
for (const [sourceSpan, resultSpan] of highlightInfo.highlightMap) {
if (
(fromSource &&
sourceSpan.start <= index &&
index <= sourceSpan.end) ||
(!fromSource && resultSpan.start <= index && index <= resultSpan.end)
) {
highlightInfo.sourceHighlight = sourceSpan;
highlightInfo.resultHighlight = resultSpan;
}
}
dispatch.current({ action: "update" });
},
resetHighlightInfo() {
highlightInfo.sourceHighlight = undefined;
highlightInfo.resultHighlight = undefined;
highlightInfo.highlightMap.clear();
},
reset() {
failure = false;
translating = false;
setStatus("Reset");
translator.reset();
this.resetHighlightInfo();
dispatch.current({ action: "update" });
},
clear() {
this.setAsm("");
dispatch.current({ action: "setTitle", payload: undefined });
dispatch.current({ action: "setCmp", payload: "" });
this.reset();
},
overrideState(state: AsmPageState) {
this.resetHighlightInfo();
this.setAsm(state.asm, state.path);
dispatch.current({
action: "setCmp",
payload: { cmp: state.compare, name: state.compareName },
});
if (state.translating) {
for (let i = 0; i <= state.current; i++) {
this.step();
}
}
dispatch.current({ action: "update" });
},
};
const initialState: AsmPageState = {
asm: "",
path: undefined,
translating: false,
current: -1,
resultHighlight: undefined,
sourceHighlight: undefined,
symbols: [],
result: "",
compare: "",
compareName: undefined,
lineNumbers: [],
compareError: false,
config: {
speed: 2,
},
};
return { initialState, reducers, actions };
}
export function useAsmPageStore() {
const { setStatus, fs, localFsRoot } = useContext(BaseContext);
const dispatch = useRef<AsmStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeAsmStore(fs, setStatus, dispatch, localFsRoot != undefined),
[setStatus, dispatch, fs],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,186 @@
import {
FileSystem,
LocalStorageFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { Action, AsyncAction } from "@nand2tetris/simulator/types.js";
import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useDialog } from "../dialog.js";
import { cloneTree } from "../file_utils.js";
import {
FileSystemAccessFileSystemAdapter,
openNand2TetrisDirectory,
} from "./base/fs.js";
import {
attemptLoadAdapterFromIndexedDb,
createAndStoreLocalAdapterInIndexedDB,
removeLocalAdapterFromIndexedDB,
} from "./base/indexDb.js";
export type StatusSeverity = "SUCCESS" | "WARNING" | "ERROR" | "INFO";
export interface BaseContext {
fs: FileSystem;
localFsRoot?: string;
canUpgradeFs: boolean;
upgradeFs: (force?: boolean, createFiles?: boolean) => Promise<void>;
closeFs: () => void;
status: { message: string; severity: StatusSeverity };
setStatus: Action<string | { message: string; severity?: StatusSeverity }>;
storage: Record<string, string>;
permissionPrompt: ReturnType<typeof useDialog>;
requestPermission: AsyncAction<void>;
loadFs: Action<void>;
}
export function useBaseContext(): BaseContext {
const localAdapter = useMemo(() => new LocalStorageFileSystemAdapter(), []);
const [fs, setFs] = useState(new FileSystem(localAdapter));
const [root, setRoot] = useState<string>();
const permissionPrompt = useDialog();
const setLocalFs = useCallback(
async (handle: FileSystemDirectoryHandle, createFiles = false) => {
// We will not mirror the changes in localStorage, since they will be saved in the user's file system
const newFs = new FileSystem(
new FileSystemAccessFileSystemAdapter(handle),
);
if (createFiles) {
if (root) {
const loaders = await import("@nand2tetris/projects/loader.js");
await loaders.createFiles(newFs);
} else {
await cloneTree(fs, newFs, "/projects", (path: string) =>
path.replace("/projects", "/").replace(/\/0*(\d+)/, "$1"),
);
}
}
setFs(newFs);
setRoot(handle.name);
},
[setRoot, setFs],
);
const requestPermission = async () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
await adapter.requestPermission({ mode: "readwrite" });
});
};
const loadFs = () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
setLocalFs(adapter);
});
};
useEffect(() => {
if (root) return;
if ("showDirectoryPicker" in window) {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
const permissions = await adapter.queryPermission({
mode: "readwrite",
});
switch (permissions) {
case "granted":
setLocalFs(adapter);
break;
case "prompt":
permissionPrompt.open();
break;
case "denied":
setStatus({
message:
"Permission denied. Please allow access to your file system.",
severity: "ERROR",
});
break;
}
});
}
}, [root, setLocalFs]);
const canUpgradeFs = `showDirectoryPicker` in window;
const upgradeFs = useCallback(
async (force = false, createFiles = false) => {
if (!canUpgradeFs || (root && !force)) return;
const handler = await openNand2TetrisDirectory();
if (root) {
await removeLocalAdapterFromIndexedDB();
}
const adapter = await createAndStoreLocalAdapterInIndexedDB(handler);
await setLocalFs(adapter, createFiles);
},
[root, setLocalFs],
);
const closeFs = useCallback(async () => {
if (!root) return;
await removeLocalAdapterFromIndexedDB();
setRoot(undefined);
setFs(new FileSystem(localAdapter));
}, [root]);
const [status, setStatusInternal] = useState<{
message: string;
severity: StatusSeverity;
}>({ message: "", severity: "INFO" });
const setStatus = useCallback(
(input: string | { message: string; severity?: StatusSeverity }) => {
if (typeof input === "string") {
setStatusInternal({ message: input, severity: "INFO" });
} else {
setStatusInternal({
message: input.message,
severity: input.severity || "INFO",
});
}
},
[],
);
return {
fs,
localFsRoot: root,
status,
setStatus,
storage: localStorage,
canUpgradeFs,
permissionPrompt,
upgradeFs,
requestPermission,
closeFs,
loadFs,
};
}
export const BaseContext = createContext<BaseContext>({
fs: new FileSystem(new LocalStorageFileSystemAdapter()),
canUpgradeFs: false,
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
async upgradeFs() {},
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
closeFs() {},
status: { message: "", severity: "INFO" },
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
setStatus() {},
storage: {},
permissionPrompt: {} as ReturnType<typeof useDialog>,
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
async requestPermission() {},
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
loadFs() {},
});
@@ -0,0 +1,176 @@
import {
basename,
FileSystemAdapter,
SEP,
Stats,
} from "@davidsouther/jiffies/lib/esm/fs.js";
function dirname(path: string): string {
return path.split(SEP).slice(0, -1).join(SEP);
}
export function openNand2TetrisDirectory(): Promise<FileSystemDirectoryHandle> {
return window.showDirectoryPicker({
id: "nand2tetris",
mode: "readwrite",
startIn: "documents",
});
}
export class FileSystemAccessFileSystemAdapter implements FileSystemAdapter {
constructor(private baseDir: FileSystemDirectoryHandle) {}
async getFolder(
path: string,
create = false,
): Promise<FileSystemDirectoryHandle> {
let folder = this.baseDir;
const parts = path
.split(SEP)
.slice(1)
.filter((part) => part.trim() != "");
for (const part of parts) {
folder = await folder.getDirectoryHandle(part, { create });
}
return folder;
}
async copyFile(from: string, to: string): Promise<void> {
throw new Error(
"unimplemented: FileSystemAccessFileSystemAdapter::copyFile",
);
}
async mkdir(path: string): Promise<void> {
this.getFolder(path, true);
}
async readFile(path: string): Promise<string> {
const folder = await this.getFolder(dirname(path));
const file = await (await folder.getFileHandle(basename(path))).getFile();
return file.text();
}
async writeFile(path: string, contents: string): Promise<void> {
const folder = await this.getFolder(dirname(path), true);
const file = await (
await folder.getFileHandle(basename(path), { create: true })
).createWritable();
await file.write(contents);
await file.close();
}
async readdir(path: string): Promise<string[]> {
const folder = await this.getFolder(path);
const entries: string[] = [];
for await (const [entry, _] of folder.entries()) {
entries.push(entry);
}
return entries;
}
async scandir(path: string): Promise<Stats[]> {
const folder = await this.getFolder(path);
const entries: Stats[] = [];
for await (const [name, handle] of folder.entries()) {
entries.push({
name,
isDirectory() {
return handle.kind == "directory";
},
isFile() {
return handle.kind == "file";
},
});
}
return entries;
}
async stat(path: string): Promise<Stats> {
const folder = await this.getFolder(dirname(path));
for await (const [name, handle] of folder.entries()) {
if (name == basename(path)) {
return {
name,
isDirectory() {
return handle.kind == "directory";
},
isFile() {
return handle.kind == "file";
},
};
}
}
return {
name: basename(path),
isDirectory() {
return false;
},
isFile() {
return false;
},
};
}
async rm(path: string): Promise<void> {
const folder = await this.getFolder(dirname(path), true);
await folder.removeEntry(basename(path), { recursive: true });
}
}
export class ChainedFileSystemAdapter implements FileSystemAdapter {
constructor(
protected adapter: FileSystemAdapter,
private nextAdapter?: FileSystemAdapter | undefined,
) {}
stat(path: string): Promise<Stats> {
return this.adapter.stat(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.stat(path);
}
throw e;
});
}
readdir(path: string): Promise<string[]> {
return this.adapter.readdir(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.readdir(path);
}
throw e;
});
}
scandir(path: string): Promise<Stats[]> {
return this.adapter.scandir(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.scandir(path);
}
throw e;
});
}
async mkdir(path: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.mkdir(path);
return this.adapter.mkdir(path);
}
async copyFile(from: string, to: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.copyFile(from, to);
return this.adapter.copyFile(from, to);
}
readFile(path: string): Promise<string> {
return this.adapter.readFile(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.readFile(path);
}
throw e;
});
}
async writeFile(path: string, contents: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter?.writeFile(path, contents);
return this.adapter.writeFile(path, contents);
}
async rm(path: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.rm(path);
return this.adapter.rm(path);
}
}
@@ -0,0 +1,96 @@
import { assert } from "@davidsouther/jiffies/lib/esm/assert.js";
const IDB_NAME = "NAND2TetrisIndexedDB";
const IDB_VERSION = 1;
const IDB_FS_ADAPTER_OBJECT_STORE = "FileSystemAccess";
const IDB_FS_ADAPTER_KEY = "Handler";
function openIndexedDb(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = window.indexedDB.open(IDB_NAME, IDB_VERSION);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
request.onupgradeneeded = (e) => {
request.result.createObjectStore(IDB_FS_ADAPTER_OBJECT_STORE);
};
});
}
export async function attemptLoadAdapterFromIndexedDb(): Promise<FileSystemDirectoryHandle | void> {
const db = await openIndexedDb();
return new Promise<FileSystemDirectoryHandle | void>((resolve, reject) => {
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readonly",
);
const objectStore = transaction.objectStore(IDB_FS_ADAPTER_OBJECT_STORE);
const handleRequest = objectStore.get(IDB_FS_ADAPTER_KEY);
handleRequest.onsuccess = () => {
const handle = handleRequest.result;
if (handle === undefined) {
resolve();
} else {
assert(
handle instanceof FileSystemDirectoryHandle,
`Retrieved ${IDB_FS_ADAPTER_KEY} in ${IDB_FS_ADAPTER_OBJECT_STORE} in ${IDB_NAME} is not a FileSystemDirectoryHandle`,
);
resolve(handle);
}
};
transaction.onerror = () => {
console.error("Error in loading FileSystemDirectoryHandle transaction", {
err: transaction.error,
});
reject(transaction.error);
};
handleRequest.onerror = () => {
console.error("Error in FileSystemDirectoryHandle handleRequest", {
err: handleRequest.error,
});
reject(handleRequest.error);
};
});
}
export async function createAndStoreLocalAdapterInIndexedDB(
handle: FileSystemDirectoryHandle,
): Promise<FileSystemDirectoryHandle> {
const db = await openIndexedDb();
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readwrite",
);
transaction
.objectStore(IDB_FS_ADAPTER_OBJECT_STORE)
.add(handle, IDB_FS_ADAPTER_KEY);
transaction.commit();
return new Promise<FileSystemDirectoryHandle>((resolve, reject) => {
transaction.oncomplete = () => {
resolve(handle);
};
transaction.onerror = () => {
reject(transaction.error);
};
});
}
export async function removeLocalAdapterFromIndexedDB() {
const db = await openIndexedDb();
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readwrite",
);
transaction
.objectStore(IDB_FS_ADAPTER_OBJECT_STORE)
.delete(IDB_FS_ADAPTER_KEY);
return new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error);
};
});
}
@@ -0,0 +1,164 @@
import {
FileSystem,
ObjectFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { cleanState } from "@davidsouther/jiffies/lib/esm/scope/state.js";
import * as not from "@nand2tetris/projects/project_01/01_not.js";
import { produce } from "immer";
import { MutableRefObject } from "react";
import { ImmPin } from "src/pinout.js";
import { ChipStoreDispatch, makeChipStore } from "./chip.store.js";
function testChipStore(
fs: Record<string, string> = {
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
},
storage: Record<string, string> = {},
) {
const dispatch: MutableRefObject<ChipStoreDispatch> = { current: jest.fn() };
const setStatus = jest.fn();
const { initialState, actions, reducers } = makeChipStore(
new FileSystem(new ObjectFileSystemAdapter(fs)),
setStatus,
storage,
dispatch,
false,
);
const store = { state: initialState, actions, reducers, dispatch, setStatus };
dispatch.current = jest.fn().mockImplementation(
// biome-ignore lint/suspicious/noExplicitAny: covariants are hard
(command: { action: keyof typeof reducers; payload: any }) => {
store.state = produce(store.state, (draft: typeof initialState) => {
reducers[command.action](draft, command.payload);
});
},
);
return store;
}
describe("ChipStore", () => {
describe("initialization", () => {
it("loads chip", async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
expect(store.state.controls.project).toBe("01");
expect(store.state.controls.chipName).toBe("Not");
expect(store.state.files.hdl).toBe(not.hdl);
expect(store.state.files.tst).toBe(not.tst);
expect(store.state.files.cmp).toBe("");
expect(store.state.files.out).toBe("");
});
});
describe("behavior", () => {
const state = cleanState(async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
return { store };
}, beforeEach);
it.todo("loads projects and chips");
it("toggles bits", async () => {
state.store.actions.toggle(state.store.state.sim.chip[0].in(), 0);
expect(state.store.state.sim.chip[0].in().busVoltage).toBe(1);
expect(state.store.dispatch.current).toHaveBeenCalledWith({
action: "updateChip",
payload: { pending: true },
});
expect(state.store.state.sim.pending).toBe(true);
state.store.actions.eval();
expect(state.store.dispatch.current).toHaveBeenCalledWith({
action: "updateChip",
payload: { pending: false },
});
expect(state.store.state.sim.pending).toBe(false);
expect(state.store.state.sim.chip[0].out().busVoltage).toBe(0);
});
});
describe("execution", () => {
const state = cleanState(async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
return { store };
}, beforeEach);
it.todo("compiles chips");
it("steps tests", async () => {
const bits = (pins: ImmPin[]) =>
pins.map((pin) => pin.bits.map((bit) => bit[1]));
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
await state.store.actions.toggleBuiltin();
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[1]]);
await state.store.actions.stepTest(); // Load, Compare To and Output List
await state.store.actions.stepTest(); // Set in 0
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[1]]);
await state.store.actions.stepTest(); // Set in 1
expect(bits(state.store.state.sim.inPins)).toEqual([[1]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
await state.store.actions.stepTest(); // No change (after end)
expect(bits(state.store.state.sim.inPins)).toEqual([[1]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
});
it("starts the cursor on the first instruction", () => {
expect(state.store.state.files.tst).toBe(not.tst);
expect(state.store.state.controls.span).toEqual({
start: 167,
end: 220,
line: 6,
});
});
it("leaves the cursor on the final character", async () => {
// Not.tst has 3 commands
await state.store.actions.stepTest();
await state.store.actions.stepTest();
await state.store.actions.stepTest();
// Past the end of the test
await state.store.actions.stepTest();
expect(state.store.state.controls.span).toEqual({
start: 269,
end: 270,
line: 16,
});
});
});
});
@@ -0,0 +1,647 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { display } from "@davidsouther/jiffies/lib/esm/display.js";
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { Err, isErr, Ok } from "@davidsouther/jiffies/lib/esm/result.js";
import {
BUILTIN_CHIP_PROJECTS,
CHIP_PROJECTS,
sortChips,
} from "@nand2tetris/projects/base.js";
import { parse as parseChip } from "@nand2tetris/simulator/chip/builder.js";
import { getBuiltinChip } from "@nand2tetris/simulator/chip/builtins/index.js";
import {
Chip,
Low,
Pin,
Chip as SimChip,
} from "@nand2tetris/simulator/chip/chip.js";
import { Clock } from "@nand2tetris/simulator/chip/clock.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { ChipTest } from "@nand2tetris/simulator/test/chiptst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { compare } from "../compare.js";
import { sortFiles } from "../file_utils.js";
import { ImmPin, reducePins } from "../pinout.js";
import { useImmerReducer } from "../react.js";
import { RunSpeed } from "../runbar.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export const NO_SCREEN = "noScreen";
export const PROJECT_NAMES = [
["01", `Project 1`],
["02", `Project 2`],
["03", `Project 3`],
["05", `Project 5`],
];
const TEST_NAMES: Record<string, string[]> = {
CPU: ["CPU", "CPU-external"],
Computer: ["ComputerAdd", "ComputerMax", "ComputerRect"],
};
export function isBuiltinOnly(
project: keyof typeof CHIP_PROJECTS,
chipName: string,
) {
return BUILTIN_CHIP_PROJECTS[project].includes(chipName);
}
function convertToBuiltin(name: string, hdl: string) {
return hdl.replace(/PARTS:([\s\S]*?)\}/, `PARTS:\n\tBUILTIN ${name};`);
}
export interface ChipPageState {
title?: string;
files: Files;
sim: ChipSim;
controls: ControlsState;
config: ChipPageConfig;
dir?: string;
}
export interface ChipPageConfig {
speed: RunSpeed;
}
export interface ChipSim {
clocked: boolean;
inPins: ImmPin[];
outPins: ImmPin[];
internalPins: ImmPin[];
chip: [Chip];
pending: boolean;
invalid: boolean;
}
export interface Files {
hdl: string;
cmp: string;
tst: string;
out: string;
}
export interface ControlsState {
projects: string[];
project: string;
chips: string[];
chipName: string;
tests: string[];
testName: string;
usingBuiltin: boolean;
runningTest: boolean;
span?: Span;
error?: CompilationError;
visualizationParameters: Set<string>;
}
export interface HDLFile {
name: string;
content: string;
}
function reduceChip(chip: SimChip, pending = false, invalid = false): ChipSim {
return {
clocked: chip.clocked,
inPins: reducePins(chip.ins),
outPins: reducePins(chip.outs),
internalPins: reducePins(chip.pins),
chip: [chip],
pending,
invalid,
};
}
const clock = Clock.get();
export type ChipStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeChipStore>["reducers"];
payload?: unknown;
}>;
export function makeChipStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<ChipStoreDispatch>,
upgraded: boolean,
) {
let _chipName = "";
let _dir = "";
let chip = new Low();
let backupHdl = "";
let tests: string[] = [];
let test = new ChipTest();
let usingBuiltin = false;
let invalid = false;
const reducers = {
setFiles(
state: ChipPageState,
{
hdl = state.files.hdl,
tst = state.files.tst,
cmp = state.files.cmp,
out = "",
}: {
hdl?: string;
tst?: string;
cmp?: string;
out?: string;
},
) {
state.files.hdl = hdl;
state.files.tst = tst;
state.files.cmp = cmp;
state.files.out = out;
},
updateChip(
state: ChipPageState,
payload?: {
pending?: boolean;
invalid?: boolean;
chipName?: string;
error?: CompilationError | undefined;
},
) {
state.sim = reduceChip(
chip,
payload?.pending ?? state.sim.pending,
payload?.invalid ?? state.sim.invalid,
);
state.controls.error = state.sim.invalid
? (payload?.error ?? state.controls.error)
: undefined;
},
setProjects(state: ChipPageState, projects: string[]) {
state.controls.projects = projects;
},
setProject(state: ChipPageState, project: keyof typeof CHIP_PROJECTS) {
state.controls.project = project;
},
setChips(state: ChipPageState, chips: string[]) {
state.controls.chips = chips;
},
setChip(
state: ChipPageState,
{ chipName, dir }: { chipName: string; dir: string },
) {
_dir = dir;
_chipName = chipName;
state.controls.chipName = chipName;
state.title = `${chipName}.hdl`;
state.controls.tests = Array.from(tests);
state.dir = dir;
},
clearChip(state: ChipPageState) {
_chipName = "";
state.controls.chipName = "";
state.title = undefined;
state.controls.tests = [];
this.setFiles(state, { hdl: "", tst: "", cmp: "", out: "" });
},
setTest(state: ChipPageState, testName: string) {
state.controls.testName = testName;
},
setVisualizationParams(state: ChipPageState, params: Set<string>) {
state.controls.visualizationParameters = new Set(params);
},
testRunning(state: ChipPageState) {
state.controls.runningTest = true;
},
testFinished(state: ChipPageState) {
state.controls.runningTest = false;
const passed = compare(state.files.cmp.trim(), state.files.out.trim());
// For some reason, this is happening during a render but I can't track it down.
Promise.resolve().then(() => {
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
});
},
updateTestStep(state: ChipPageState) {
state.files.out = test?.log() ?? "";
if (test?.currentStep?.span) {
state.controls.span = test.currentStep.span;
} else {
if (test.done) {
const end = state.files.tst.length;
state.controls.span = {
start: end - 1,
end,
line: state.files.tst.split("\n").length,
};
}
}
this.updateChip(state, {
pending: state.sim.pending,
invalid: state.sim.invalid,
});
},
updateConfig(state: ChipPageState, config: Partial<ChipPageConfig>) {
state.config = { ...state.config, ...config };
},
updateUsingBuiltin(state: ChipPageState) {
state.controls.usingBuiltin = usingBuiltin;
},
displayBuiltin(state: ChipPageState) {
backupHdl = state.files.hdl;
this.setFiles(state, {
hdl: convertToBuiltin(state.controls.chipName, state.files.hdl),
});
},
toggleBuiltin(state: ChipPageState) {
state.controls.usingBuiltin = usingBuiltin;
if (usingBuiltin) {
this.displayBuiltin(state);
} else {
this.setFiles(state, { hdl: backupHdl });
}
},
};
const actions = {
async initialize() {
const projectsFolder = upgraded ? "/" : "/projects";
const entries = await fs.scandir(projectsFolder);
const hdlProjects = [];
for (const project of entries.filter((project) =>
project.isDirectory(),
)) {
const items = await fs.scandir(`${projectsFolder}/${project.name}`);
if (items.some((item) => item.isFile() && item.name.endsWith(".hdl"))) {
hdlProjects.push(project);
}
}
const sortedNames = sortFiles(hdlProjects).map((project) => project.name);
dispatch.current({
action: "setProjects",
payload: sortedNames,
});
if (hdlProjects.length > 0) {
await actions.setProject(sortedNames[0]);
} else {
dispatch.current({ action: "setChips", payload: [] });
}
dispatch.current({ action: "clearChip" });
},
reset() {
Clock.get().reset();
chip.reset();
test.reset();
dispatch.current({ action: "setFiles", payload: {} });
dispatch.current({ action: "updateChip" });
},
async setProject(project: string) {
storage["/chip/project"] = project;
dispatch.current({ action: "setProject", payload: project });
const prefix = upgraded ? "/" : "/projects";
const chips = (await fs.scandir(`${prefix}/${project}`))
.filter((entry) => entry.isFile() && entry.name.endsWith(".hdl"))
.map((file) => file.name.replace(".hdl", ""));
const payload = sortChips(project, chips);
dispatch.current({ action: "setChips", payload });
if (chips.length > 0) {
this.loadChip(`${prefix}/${project}/${chips[0]}.hdl`, true);
}
},
async loadChip(path: string, loadTests = true) {
dispatch.current({ action: "updateUsingBuiltin", payload: false });
const hdl = await fs.readFile(path);
const parts = path.split("/");
const name = assertExists(parts.pop()).replace(".hdl", "");
const dir = parts.join("/");
await this.compileChip(hdl, dir, name);
if (loadTests) {
await this.initializeTests(dir, name);
}
dispatch.current({
action: "setChip",
payload: { chipName: name, dir: dir },
});
dispatch.current({ action: "setFiles", payload: { hdl } });
if (usingBuiltin) {
this.loadBuiltin();
dispatch.current({ action: "displayBuiltin" });
}
},
async compileChip(hdl: string, dir?: string, name?: string) {
chip.remove();
const maybeChip = await parseChip(hdl, dir, name, fs);
if (isErr(maybeChip)) {
const error = Err(maybeChip);
setStatus({
message: Err(maybeChip).message,
severity: "ERROR",
});
invalid = true;
dispatch.current({
action: "updateChip",
payload: { invalid: true, error },
});
return;
}
this.replaceChip(Ok(maybeChip));
},
replaceChip(nextChip: SimChip) {
// Store current inPins
const inPins = chip.ins;
for (const [pin, { busVoltage }] of inPins) {
const nextPin = nextChip.ins.get(pin);
if (nextPin) {
nextPin.busVoltage = busVoltage;
}
}
clock.reset();
nextChip.eval();
chip = nextChip;
chip.reset();
dispatch.current({ action: "updateChip", payload: { invalid: false } });
dispatch.current({ action: "updateTestStep" });
},
async initializeTests(dir: string, chip: string) {
tests = TEST_NAMES[chip] ?? [];
this.loadTest(tests[0] ?? chip, dir);
},
async loadTest(name: string, dir?: string) {
if (!fs) return;
try {
dir ??= _dir;
const tst = await fs.readFile(`${dir}/${name}.tst`);
dispatch.current({ action: "setFiles", payload: { tst, cmp: "" } });
dispatch.current({ action: "setTest", payload: name });
this.compileTest(tst, dir);
} catch (e) {
setStatus({
message: `Could not find ${name}.tst. Please load test file separately.`,
severity: "WARNING",
});
console.error(e);
}
},
compileTest(file: string, path: string) {
if (!fs) return;
dispatch.current({ action: "setFiles", payload: { tst: file } });
const tst = TST.parse(file);
if (isErr(tst)) {
setStatus({
message: `Failed to parse test ${Err(tst).message}`,
severity: "ERROR",
});
invalid = true;
return false;
}
const maybeTest = ChipTest.from(Ok(tst), {
dir: path,
setStatus: setStatus,
loadAction: async (file) => {
await this.loadChip(file, false);
return chip;
},
compareTo: async (file) => {
const cmp = await fs.readFile(`${_dir}/${file}`);
dispatch.current({ action: "setFiles", payload: { cmp } });
},
});
if (isErr(maybeTest)) {
invalid = true;
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest).with(chip).reset();
test.setFileSystem(fs);
dispatch.current({ action: "updateTestStep" });
return true;
}
},
async updateFiles({
hdl,
tst,
cmp,
tstPath,
}: {
hdl?: string;
tst?: string;
cmp: string;
tstPath?: string;
}) {
invalid = false;
dispatch.current({ action: "setFiles", payload: { hdl, tst, cmp } });
try {
if (hdl) {
await this.compileChip(hdl, _dir, _chipName);
}
if (tst) {
this.compileTest(tst, tstPath ?? _dir);
}
} catch (e) {
setStatus({
message: display(e),
severity: "ERROR",
});
}
dispatch.current({ action: "updateChip", payload: { invalid: invalid } });
if (!invalid) {
setStatus(`HDL code: No syntax errors`);
}
},
async saveChip(hdl: string) {
dispatch.current({ action: "setFiles", payload: { hdl } });
const path = `${_dir}/${_chipName}.hdl`;
if (fs && path) {
await fs.writeFile(path, hdl);
}
},
toggle(pin: Pin, i: number | undefined) {
if (i !== undefined) {
pin.busVoltage = pin.busVoltage ^ (1 << i);
} else {
if (pin.width === 1) {
pin.toggle();
} else {
pin.busVoltage += 1;
}
}
dispatch.current({ action: "updateChip", payload: { pending: true } });
},
eval() {
chip.eval();
dispatch.current({ action: "updateChip", payload: { pending: false } });
},
clock() {
clock.toggle();
if (clock.isLow) {
clock.frame();
}
dispatch.current({ action: "updateChip" });
},
async loadBuiltin() {
const builtinName = _chipName;
const nextChip = await getBuiltinChip(builtinName);
if (isErr(nextChip)) {
setStatus({
message: `Failed to load builtin ${builtinName}: ${display(Err(nextChip))}`,
severity: "ERROR",
});
return;
}
this.replaceChip(Ok(nextChip));
},
async toggleBuiltin() {
usingBuiltin = !usingBuiltin;
dispatch.current({ action: "toggleBuiltin" });
if (usingBuiltin) {
await this.loadBuiltin();
} else {
await this.compileChip(backupHdl, _dir, _chipName);
}
},
tick(): Promise<boolean> {
return this.stepTest();
},
async stepTest(): Promise<boolean> {
try {
const done = await test.step();
dispatch.current({ action: "updateTestStep" });
if (done) {
dispatch.current({ action: "testFinished" });
}
return done;
} catch (e) {
setStatus({
message: (e as Error).message,
severity: "ERROR",
});
return true;
}
},
async getProjectFiles() {
console.log(_dir);
return (await fs.scandir(_dir))
.filter((entry) => entry.isFile() && entry.name.endsWith(".hdl"))
.map((entry) => ({
name: entry.name,
content: fs.readFile(`${_dir}/${entry.name}`),
}));
},
};
const initialState: ChipPageState = (() => {
const controls: ControlsState = {
projects: ["1", "2", "3", "5"],
project: "1",
chips: [],
chipName: "",
tests,
testName: "",
usingBuiltin: false,
runningTest: false,
error: undefined,
visualizationParameters: new Set(),
};
const sim = reduceChip(new Low());
return {
controls,
files: {
hdl: "",
cmp: "",
tst: "",
out: "",
backupHdl: "",
},
sim,
config: { speed: 2 },
};
})();
return { initialState, reducers, actions };
}
export function useChipPageStore() {
const { fs, setStatus, storage, localFsRoot } = useContext(BaseContext);
const dispatch = useRef<ChipStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() =>
makeChipStore(fs, setStatus, storage, dispatch, localFsRoot != undefined),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,175 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { compile } from "@nand2tetris/simulator/jack/compiler.js";
import { CompilationError } from "@nand2tetris/simulator/languages/base.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export interface CompiledFile {
vm?: string;
valid: boolean;
error?: CompilationError;
}
export interface CompilerPageState {
fs?: FileSystem;
files: Record<string, string>;
compiled: Record<string, CompiledFile>;
isValid: boolean;
isCompiled: boolean;
selected: string;
title?: string;
}
export type CompilerStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeCompilerStore>["reducers"];
payload?: unknown;
}>;
function classTemplate(name: string) {
return `class ${name} {\n\n}\n`;
}
export function makeCompilerStore(
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
dispatch: MutableRefObject<CompilerStoreDispatch>,
) {
let fs: FileSystem | undefined;
const reducers = {
setFs(state: CompilerPageState, fs: FileSystem) {
state.fs = fs;
},
reset(state: CompilerPageState) {
state.files = {};
state.title = undefined;
},
setFile(
state: CompilerPageState,
{ name, content }: { name: string; content: string },
) {
state.files[name] = content;
state.isCompiled = false;
this.compile(state);
},
// the keys of 'files' have to be the full file path, not basename
setFiles(state: CompilerPageState, files: Record<string, string>) {
state.files = files;
state.isCompiled = false;
this.compile(state);
},
compile(state: CompilerPageState) {
const compiledFiles = compile(state.files);
state.compiled = {};
for (const [name, compiled] of Object.entries(compiledFiles)) {
if (typeof compiled === "string") {
state.compiled[name] = {
valid: true,
vm: compiled,
};
} else {
state.compiled[name] = {
valid: false,
error: compiled,
};
}
}
state.isValid = Object.keys(state.files).every(
(file) => state.compiled[file].valid,
);
},
writeCompiled(state: CompilerPageState) {
if (Object.values(state.compiled).every((compiled) => compiled.valid)) {
for (const [name, compiled] of Object.entries(state.compiled)) {
if (compiled.vm) {
fs?.writeFile(`${name}.vm`, compiled.vm);
}
}
}
state.isCompiled = true;
},
setSelected(state: CompilerPageState, selected: string) {
state.selected = selected;
},
setTitle(state: CompilerPageState, title: string) {
state.title = title;
},
};
const actions = {
async loadProject(_fs: FileSystem, title: string) {
this.reset();
fs = _fs;
dispatch.current({ action: "setFs", payload: fs });
dispatch.current({ action: "setTitle", payload: title });
const files: Record<string, string> = {};
for (const file of (await fs.scandir("/")).filter(
(entry) => entry.isFile() && entry.name.endsWith(".jack"),
)) {
files[file.name.replace(".jack", "")] = await fs.readFile(file.name);
}
this.loadFiles(files);
},
async loadFiles(files: Record<string, string>) {
dispatch.current({ action: "setFiles", payload: files });
if (Object.entries(files).length > 0) {
dispatch.current({
action: "setSelected",
payload: Object.keys(files)[0],
});
}
},
async writeFile(name: string, content?: string) {
content ??= classTemplate(name);
dispatch.current({ action: "setFile", payload: { name, content } });
if (fs) {
await fs.writeFile(`${name}.jack`, content);
}
},
async reset() {
fs = undefined;
dispatch.current({ action: "reset" });
},
async compile() {
dispatch.current({ action: "writeCompiled" });
},
};
const initialState: CompilerPageState = {
files: {},
compiled: {},
selected: "",
isCompiled: false,
isValid: true,
};
return { initialState, reducers, actions };
}
export function useCompilerPageStore() {
const { setStatus } = useContext(BaseContext);
const dispatch = useRef<CompilerStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeCompilerStore(setStatus, dispatch),
[setStatus, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,355 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs";
import {
Err,
isErr,
Ok,
unwrap,
} from "@davidsouther/jiffies/lib/esm/result.js";
import {
Format,
KeyboardAdapter,
MemoryAdapter,
MemoryKeyboard,
ROM,
} from "@nand2tetris/simulator/cpu/memory.js";
import { Span } from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { loadAsm, loadBlob, loadHack } from "@nand2tetris/simulator/loader.js";
import { CPUTest } from "@nand2tetris/simulator/test/cputst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { ScreenScales } from "src/chips/screen.js";
import { RunSpeed } from "src/runbar.js";
import { compare } from "../compare.js";
import { loadTestFiles } from "../file_utils.js";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
import { ImmMemory } from "./imm_memory.js";
function makeTst() {
return `repeat {
ticktock;
}`;
}
export interface CpuSim {
A: number;
D: number;
PC: number;
RAM: MemoryAdapter;
ROM: MemoryAdapter;
Screen: MemoryAdapter;
Keyboard: KeyboardAdapter;
}
export interface CPUTestSim {
name: string;
tst: string;
cmp: string;
out: string;
highlight: Span | undefined;
valid: boolean;
}
export interface CPUPageConfig {
romFormat: Format;
ramFormat: Format;
screenScale: ScreenScales;
speed: RunSpeed;
testSpeed: RunSpeed;
}
export interface CpuPageState {
sim: CpuSim;
test: CPUTestSim;
path: string;
tests: string[];
title?: string;
config: CPUPageConfig;
}
function reduceCPUTest(
cpuTest: CPUTest,
dispatch: MutableRefObject<CpuStoreDispatch>,
): CpuSim {
const RAM = new ImmMemory(cpuTest.cpu.RAM, dispatch);
const ROM = new ImmMemory(cpuTest.cpu.ROM, dispatch);
const Screen = new ImmMemory(cpuTest.cpu.Screen, dispatch);
const Keyboard = new MemoryKeyboard(new ImmMemory(cpuTest.cpu.RAM, dispatch));
return {
A: cpuTest.cpu.A,
D: cpuTest.cpu.D,
PC: cpuTest.cpu.PC,
RAM,
ROM,
Screen,
Keyboard,
};
}
export type CpuStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeCpuStore>["reducers"];
payload?: unknown;
}>;
export function makeCpuStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<CpuStoreDispatch>,
) {
let test = new CPUTest();
let animate = true;
let valid = true;
let path = "";
let tests: string[] = [];
let tstName = "";
let _title: string | undefined;
const reducers = {
update(state: CpuPageState) {
state.sim = reduceCPUTest(test, dispatch);
state.test.highlight = test.currentStep?.span;
state.test.valid = valid;
state.path = path;
state.tests = Array.from(tests);
state.test.name = tstName;
},
setTest(state: CpuPageState, { tst, cmp }: { tst?: string; cmp?: string }) {
state.test.tst = tst ?? state.test.tst;
state.test.cmp = cmp ?? state.test.cmp;
state.test.out = "";
},
testStep(state: CpuPageState) {
state.test.out = test.log();
this.update(state);
},
testFinished(state: CpuPageState) {
if (state.test.cmp.trim() === "") {
return;
}
const passed = compare(state.test.cmp.trim(), test.log().trim());
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
},
setTitle(state: CpuPageState, title?: string) {
_title = title;
state.title = title;
if (title) {
test.fileLoaded = true;
}
},
updateConfig(state: CpuPageState, config: Partial<CPUPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const actions = {
tick() {
test.cpu.tick();
},
setAnimate(value: boolean) {
animate = value;
},
async setPath(_path: string) {
path = _path;
const dir = path.split("/").slice(0, -1).join("/");
const files = await fs.scandir(dir);
tests = files
.filter((file) => file.name.endsWith(".tst"))
.map((file) => file.name);
if (tests.length > 0) {
this.loadTest(tests[0]);
} else {
tstName = "Default";
this.compileTest(makeTst(), "");
}
dispatch.current({ action: "update" });
},
async testStep() {
try {
const done = await test.step();
if (animate || done) {
dispatch.current({ action: "testStep" });
}
if (done) {
dispatch.current({ action: "testFinished" });
}
return done;
} catch (e) {
setStatus({
message: (e as Error).message,
severity: "ERROR",
});
return true;
}
},
resetRAM() {
test.cpu.RAM.loadBytes([]);
dispatch.current({ action: "update" });
setStatus("Reset RAM");
},
toggleUseTest() {
dispatch.current({ action: "update" });
},
reset() {
test.reset();
dispatch.current({ action: "update" });
},
clear() {
this.replaceROM(new ROM());
this.resetRAM();
this.clearTest();
this.reset();
dispatch.current({ action: "setTitle", payload: undefined });
},
clearTest() {
tstName = "";
this.compileTest(makeTst(), "");
dispatch.current({ action: "update" });
},
replaceROM(rom: ROM) {
test = new CPUTest({ dir: path, rom });
this.clearTest();
},
compileTest(file: string, cmp?: string, _path?: string) {
const tstPath = _path ?? path;
dispatch.current({ action: "setTest", payload: { tst: file, cmp } });
const tst = TST.parse(file);
if (isErr(tst)) {
setStatus({
message: `Failed to parse test - ${Err(tst).message}`,
severity: "ERROR",
});
valid = false;
dispatch.current({ action: "update" });
return false;
}
valid = true;
const maybeTest = CPUTest.from(Ok(tst), {
dir: tstPath,
rom: test.cpu.ROM,
doEcho: setStatus,
doLoad: async (path) => {
let file;
try {
file = await fs.readFile(path);
} catch (_e) {
throw new Error(`Cannot find ${path}`);
}
const loader = path.endsWith("hack")
? loadHack
: path.endsWith("asm")
? loadAsm
: loadBlob;
const bytes = await loader(file);
console.log(bytes);
test.cpu.ROM.loadBytes(bytes);
},
compareTo: async (file) => {
const dir = tstPath.split("/").slice(0, -1).join("/");
const cmp = await fs.readFile(`${dir}/${file}`);
dispatch.current({ action: "setTest", payload: { cmp } });
},
requireLoad: false,
});
if (isErr(maybeTest)) {
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest);
test.fileLoaded = _title != undefined;
dispatch.current({ action: "update" });
return true;
}
},
async loadTest(name: string) {
const dir = path.split("/").slice(0, -1).join("/");
const files = await loadTestFiles(fs, `${dir}/${name}`);
if (isErr(files)) {
setStatus({
message: `Failed to load test`,
severity: "ERROR",
});
return;
}
tstName = name;
const { tst } = unwrap(files);
this.compileTest(tst, "");
},
};
const initialState: CpuPageState = {
sim: reduceCPUTest(test, dispatch),
test: {
highlight: test.currentStep?.span,
name: "",
tst: makeTst(),
cmp: "",
out: "",
valid: true,
},
path: "",
tests: [],
config: {
romFormat: "asm",
ramFormat: "dec",
screenScale: 1,
speed: 2,
testSpeed: 2,
},
};
return { initialState, reducers, actions };
}
export function useCpuPageStore() {
const { fs, setStatus, storage } = useContext(BaseContext);
const dispatch = useRef<CpuStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeCpuStore(fs, setStatus, storage, dispatch),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,20 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { MemoryAdapter, SubMemory } from "@nand2tetris/simulator/cpu/memory.js";
import { MutableRefObject } from "react";
export class ImmMemory<
Action extends { action: "update" },
Dispatch extends (a: Action) => void,
> extends SubMemory {
constructor(
parent: MemoryAdapter,
private dispatch: MutableRefObject<Dispatch>,
) {
super(parent, parent.size, 0);
}
override async load(fs: FileSystem, path: string): Promise<void> {
await super.load(fs, path);
this.dispatch.current({ action: "update" } as Action);
}
}
@@ -0,0 +1,465 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import {
Err,
isErr,
Ok,
Result,
unwrap,
} from "@davidsouther/jiffies/lib/esm/result.js";
import { FIBONACCI } from "@nand2tetris/projects/base.js";
import {
Format,
KeyboardAdapter,
MemoryAdapter,
MemoryKeyboard,
} from "@nand2tetris/simulator/cpu/memory.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { VM, VmInstruction } from "@nand2tetris/simulator/languages/vm.js";
import { VMTest, VmFile } from "@nand2tetris/simulator/test/vmtst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Vm, VmFrame } from "@nand2tetris/simulator/vm/vm.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { ScreenScales } from "../chips/screen.js";
import { compare } from "../compare.js";
import { useImmerReducer } from "../react.js";
import { RunSpeed } from "../runbar.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
import { ImmMemory } from "./imm_memory.js";
export const DEFAULT_TEST = "repeat {\n\tvmstep;\n}";
export interface VmSim {
RAM: MemoryAdapter;
Screen: MemoryAdapter;
Keyboard: KeyboardAdapter;
Stack: VmFrame[];
Prog: VmInstruction[];
Statics: number[];
Temp: number[];
AddedSysInit: boolean;
highlight: number;
showHighlight: boolean;
}
export interface VMTestSim {
highlight: Span | undefined;
}
export interface VmPageState {
vm: VmSim;
controls: ControlsState;
test: VMTestSim;
files: VMFiles;
title?: string;
config: VmPageConfig;
}
export interface VmPageConfig {
ram1Format: Format;
ram2Format: Format;
screenScale: ScreenScales;
speed: RunSpeed;
testSpeed: RunSpeed;
}
export interface ControlsState {
runningTest: boolean;
exitCode: number | undefined;
animate: boolean;
valid: boolean;
error?: CompilationError;
}
export interface VMFiles {
vm: string;
tst: string;
cmp: string;
out: string;
}
export type VmStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeVmStore>["reducers"];
payload?: unknown;
}>;
function reduceVMTest(
vmTest: VMTest,
dispatch: MutableRefObject<VmStoreDispatch>,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
showHighlight: boolean,
): VmSim {
const RAM = new ImmMemory(vmTest.vm.RAM, dispatch);
const Screen = new ImmMemory(vmTest.vm.Screen, dispatch);
const Keyboard = new MemoryKeyboard(new ImmMemory(vmTest.vm.RAM, dispatch));
const highlight = vmTest.vm.derivedLine();
let stack: VmFrame[] = [];
try {
stack = vmTest.vm.vmStack().reverse();
} catch (_e) {
dispatch.current({
action: "setError",
payload: new Error("Runtime error: Invalid stack"),
});
}
return {
Keyboard,
RAM,
Screen,
Stack: stack,
Prog: vmTest.vm.program,
Statics: [
...vmTest.vm.memory.map((_, v) => v, 16, 16 + vmTest.vm.getStaticCount()),
],
Temp: [...vmTest.vm.memory.map((_, v) => v, 5, 13)],
AddedSysInit: vmTest.vm.addedSysInit,
highlight,
showHighlight,
};
}
export function makeVmStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<VmStoreDispatch>,
) {
const parsed = unwrap(VM.parse(FIBONACCI));
let vm = unwrap(Vm.build(parsed.instructions));
let test = new VMTest({ doEcho: setStatus }).with(vm);
let useTest = false;
let animate = true;
let vmSource = "";
let showHighlight = true;
const reducers = {
setVm(state: VmPageState, vm: string) {
state.files.vm = vm;
},
setTst(state: VmPageState, { tst }: { tst: string }) {
state.files.tst = tst;
},
setCmp(state: VmPageState, { cmp }: { cmp: string }) {
state.files.cmp = cmp;
},
setExitCode(state: VmPageState, code: number | undefined) {
state.controls.exitCode = code;
},
setValid(state: VmPageState, valid: boolean) {
state.controls.valid = valid;
},
setShowHighlight(state: VmPageState, value: boolean) {
state.vm.showHighlight = value;
},
setError(state: VmPageState, error?: CompilationError) {
if (error) {
state.controls.valid = false;
setStatus({
message: error?.message,
severity: "ERROR",
});
} else {
state.controls.valid = true;
}
state.controls.error = error;
},
update(state: VmPageState) {
state.vm = reduceVMTest(test, dispatch, setStatus, showHighlight);
state.test.highlight = test.currentStep?.span;
},
setAnimate(state: VmPageState, value: boolean) {
state.controls.animate = value;
},
testStep(state: VmPageState) {
state.files.out = test.log();
},
testFinished(state: VmPageState) {
const passed = compare(state.files.cmp.trim(), state.files.out);
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
},
setTitle(state: VmPageState, title: string) {
state.title = title;
},
updateConfig(state: VmPageState, config: Partial<VmPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const initialState: VmPageState = {
vm: reduceVMTest(test, dispatch, setStatus, true),
controls: {
exitCode: undefined,
runningTest: false,
animate: true,
valid: true,
},
test: {
highlight: undefined,
},
files: {
vm: "",
tst: DEFAULT_TEST,
cmp: "",
out: "",
},
config: {
ram1Format: "dec",
ram2Format: "dec",
screenScale: 1,
speed: 2,
testSpeed: 2,
},
};
const actions = {
async load(path: string) {
const files: VmFile[] = [];
let title: string;
if ((await fs.stat(path)).isFile()) {
// single file
files.push({
name: assertExists(path.split("/").pop()).replace(".vm", ""),
content: await fs.readFile(path),
});
title = path.split("/").pop() ?? "";
} else {
// folder
for (const file of (await fs.scandir(path)).filter(
(entry) => entry.isFile() && entry.name.endsWith(".vm"),
)) {
files.push({
name: file.name.replace(".vm", ""),
content: await fs.readFile(`${path}/${file.name}`),
});
}
title = `${path.split("/").pop()} / *.vm`;
}
dispatch.current({ action: "setTitle", payload: title });
this.loadVm(files);
this.reset();
setStatus("");
},
setVm(content: string) {
showHighlight = false;
dispatch.current({
action: "setVm",
payload: content,
});
if (vmSource == content) {
return;
}
vmSource = content;
const parseResult = VM.parse(content);
if (isErr(parseResult)) {
dispatch.current({ action: "setError", payload: Err(parseResult) });
return false;
}
const instructions = unwrap(parseResult).instructions;
const buildResult = Vm.build(instructions);
return this.replaceVm(buildResult);
},
loadVm(files: VmFile[]) {
showHighlight = false;
const content = files.map((f) => f.content).join("\n");
dispatch.current({
action: "setVm",
payload: content,
});
if (vmSource == content) {
return;
}
vmSource = content;
const parsed = [];
let lineOffset = 0;
for (const file of files) {
const parseResult = VM.parse(file.content);
if (isErr(parseResult)) {
dispatch.current({ action: "setError", payload: Err(parseResult) });
return false;
}
const instructions = unwrap(parseResult).instructions;
for (const instruction of instructions) {
if (instruction.span?.line != undefined) {
instruction.span.line += lineOffset;
}
}
lineOffset += file.content.split("\n").length;
parsed.push({
name: file.name,
instructions,
});
}
const buildResult = Vm.buildFromFiles(parsed);
return this.replaceVm(buildResult);
},
replaceVm(buildResult: Result<Vm, CompilationError>) {
if (isErr(buildResult)) {
dispatch.current({ action: "setError", payload: Err(buildResult) });
return false;
}
dispatch.current({ action: "setError" });
// setStatus("Compiled VM code successfully");
vm = unwrap(buildResult);
test.vm = vm;
dispatch.current({ action: "update" });
return true;
},
loadTest(path: string, source: string) {
dispatch.current({ action: "setTst", payload: { tst: source } });
const tst = TST.parse(source);
if (isErr(tst)) {
dispatch.current({ action: "setValid", payload: false });
setStatus({
message: `Failed to parse test`,
severity: "ERROR",
});
return false;
}
dispatch.current({ action: "setValid", payload: true });
setStatus(`Parsed tst`);
vm.reset();
const maybeTest = VMTest.from(unwrap(tst), {
dir: path,
doLoad: async (path) => {
await this.load(path);
},
doEcho: setStatus,
compareTo: async (file) => {
const dir = path.split("/").slice(0, -1).join("/");
const cmp = await fs.readFile(`${dir}/${file}`);
dispatch.current({ action: "setCmp", payload: { cmp } });
},
});
if (isErr(maybeTest)) {
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest).using(fs);
test.vm = vm;
dispatch.current({ action: "update" });
return true;
}
},
setAnimate(value: boolean) {
animate = value;
dispatch.current({ action: "setAnimate", payload: value });
},
async testStep() {
showHighlight = true;
let done = false;
try {
done = await test.step();
dispatch.current({ action: "testStep" });
if (done) {
dispatch.current({ action: "testFinished" });
}
if (animate) {
dispatch.current({ action: "update" });
}
return done;
} catch (e) {
setStatus({
message: `Runtime error: ${(e as Error).message}`,
severity: "ERROR",
});
dispatch.current({ action: "setError", payload: e });
return true;
}
},
setPaused(paused = true) {
vm.setPaused(paused);
},
step() {
showHighlight = true;
try {
let done = false;
const exitCode = vm.step();
if (exitCode !== undefined) {
done = true;
dispatch.current({ action: "setExitCode", payload: exitCode });
}
if (animate) {
dispatch.current({ action: "update" });
}
return done;
} catch (e) {
setStatus({
message: `Runtime error: ${(e as Error).message}`,
severity: "ERROR",
});
dispatch.current({ action: "setError", payload: e });
return true;
}
},
reset() {
showHighlight = true;
test.reset();
vm.reset();
dispatch.current({ action: "update" });
dispatch.current({ action: "setExitCode", payload: undefined });
dispatch.current({ action: "setValid", payload: true });
},
toggleUseTest() {
useTest = !useTest;
dispatch.current({ action: "update" });
},
initialize() {
dispatch.current({ action: "setTitle", payload: undefined });
this.setVm(FIBONACCI);
},
};
return { initialState, reducers, actions };
}
export function useVmPageStore() {
const { fs, setStatus, storage } = useContext(BaseContext);
const dispatch = useRef<VmStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeVmStore(fs, setStatus, storage, dispatch),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}