diff --git a/src/pages/edit/Editor/components/SimulationErrorAlert.tsx b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx
new file mode 100644
index 0000000..575bd5e
--- /dev/null
+++ b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx
@@ -0,0 +1,29 @@
+import { Alert, Snackbar } from "@mui/material";
+import { useComponentEditorStore } from "../store";
+
+/**
+ * Shows why the circuit could not be simulated, e.g. because a loop needs a flip-flop
+ * to break it or because an input pin is left unconnected.
+ */
+export default function CCComponentEditorSimulationErrorAlert() {
+ const componentEditorState = useComponentEditorStore()();
+ const message = componentEditorState.simulationError;
+
+ return (
+
+ {
+ componentEditorState.dismissSimulationError();
+ }}
+ >
+ {message}
+
+
+ );
+}
diff --git a/src/pages/edit/Editor/index.tsx b/src/pages/edit/Editor/index.tsx
index 7512744..e593c14 100644
--- a/src/pages/edit/Editor/index.tsx
+++ b/src/pages/edit/Editor/index.tsx
@@ -9,6 +9,7 @@ import CCComponentEditorContextMenu from "./components/ContextMenu";
import CCComponentEditorFrameControl from "./components/FrameControl";
import CCComponentEditorGrid from "./components/Grid";
import { CCComponentEditorNodePinPropertyEditor } from "./components/NodePinPropertyEditor";
+import CCComponentEditorSimulationErrorAlert from "./components/SimulationErrorAlert";
import CCComponentEditorTitleBar from "./components/TitleBar";
import CCComponentEditorViewModeSwitcher from "./components/ViewModeSwitcher";
import CCComponentEditorRenderer from "./renderer";
@@ -50,6 +51,7 @@ function CCComponentEditorContent({
+
{isComponentPropertyDialogOpen && (
{
@@ -104,11 +106,17 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator<
setRangeSelect(rangeSelect) {
set((state) => ({ ...state, rangeSelect }));
},
+ simulationError: null,
+ dismissSimulationError() {
+ set((state) => ({ ...state, simulationError: null }));
+ },
setEditorMode(mode) {
+ hasSimulationFailed = false;
set((state) => ({
...state,
editorMode: mode,
playbackMode: "manual",
+ simulationError: null,
}));
},
setTimeStep(timeStep: number) {
@@ -152,12 +160,10 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator<
}));
},
getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined {
- const { nodeId } = nullthrows(store.nodePins.get(nodePinId));
return nullthrows(
- nullthrows(simulationCachedFrames[get().timeStep]).nodes.get(
- nodeId,
- ),
- ).pins.get(nodePinId);
+ simulationCachedFrames[get().timeStep],
+ `Time step ${get().timeStep} has not been simulated`,
+ ).getNodePinValue(nodePinId);
},
getComponentPinValue(
componentPinId: CCComponentPinId,
@@ -175,52 +181,62 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator<
const executeSimulation = () => {
if (editorStore.getState().editorMode !== "play") return;
- const newSimulationCacheKey =
- store.nodes
- .getMany()
- .map((node) => node.id)
- .join() +
- store.nodePins
- .getMany()
- .map((nodePin) => `${nodePin.id}_${nodePin.manualBitWidth || 0}`)
- .join(",") +
- store.connections
- .getMany()
- .map((connection) => connection.id)
- .join() +
- [...editorStore.getState().inputValues.entries()]
- .map(([key, value]) => key + value.join())
- .join();
+ // The netlist is cached against the revision of the store, so the key only
+ // has to cover that revision and the values fed to the input pins.
+ const newSimulationCacheKey = [
+ store.revision,
+ ...[...editorStore.getState().inputValues.entries()].map(
+ ([key, value]) => `${key}${value.join()}`,
+ ),
+ ].join("|");
if (newSimulationCacheKey !== simulationCacheKey) {
simulationCacheKey = newSimulationCacheKey;
simulationCachedFrames = [];
+ hasSimulationFailed = false;
}
+ if (hasSimulationFailed) return;
const editorState = editorStore.getState();
let isUpdated = false;
- for (
- let timeStep = simulationCachedFrames.length;
- timeStep <= editorState.timeStep;
- timeStep += 1
- ) {
- const previousFrame = simulationCachedFrames[timeStep - 1] ?? null;
- const inputValues = new Map();
- const pins = store.componentPins.getManyByComponentId(componentId);
- for (const pin of pins) {
- invariant(pin.implementation);
- if (pin.type === "input") {
- inputValues.set(
- pin.id,
- editorState.getInputValue({ componentPinId: pin.id, timeStep }),
- );
+ try {
+ for (
+ let timeStep = simulationCachedFrames.length;
+ timeStep <= editorState.timeStep;
+ timeStep += 1
+ ) {
+ const previousFrame = simulationCachedFrames[timeStep - 1] ?? null;
+ const inputValues = new Map();
+ const pins = store.componentPins.getManyByComponentId(componentId);
+ for (const pin of pins) {
+ invariant(pin.implementation);
+ if (pin.type === "input") {
+ inputValues.set(
+ pin.id,
+ editorState.getInputValue({
+ componentPinId: pin.id,
+ timeStep,
+ }),
+ );
+ }
}
- }
- simulationCachedFrames.push(
- nullthrows(
+ simulationCachedFrames.push(
simulateComponent(store, componentId, inputValues, previousFrame),
- ),
- );
- isUpdated = true;
+ );
+ isUpdated = true;
+ }
+ } catch (error) {
+ // Back to edit mode: without frames the renderers have no values to show.
+ hasSimulationFailed = true;
+ simulationCachedFrames = [];
+ editorStore.setState((state) => ({
+ ...state,
+ editorMode: "edit",
+ playbackMode: "manual",
+ timeStep: 0,
+ simulationError:
+ error instanceof Error ? error.message : String(error),
+ }));
+ return;
}
if (isUpdated) editorStore.setState((s) => ({ ...s }));
};
diff --git a/src/pages/edit/Editor/store/slices/core/types.ts b/src/pages/edit/Editor/store/slices/core/types.ts
index c9863a1..dab3a47 100644
--- a/src/pages/edit/Editor/store/slices/core/types.ts
+++ b/src/pages/edit/Editor/store/slices/core/types.ts
@@ -63,6 +63,9 @@ export type EditorStoreCoreSlice = {
selectNode(ids: CCNodeId[], exclusive: boolean): void;
unselectNode(ids: CCNodeId[]): void;
selectConnection(ids: CCConnectionId[], exclusive: boolean): void;
+ /** message the last simulation attempt failed with, if it failed */
+ simulationError: string | null;
+ dismissSimulationError(): void;
getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined;
getComponentPinValue(
componentPinId: CCComponentPinId,
diff --git a/src/store/index.ts b/src/store/index.ts
index 150b03a..fcf6621 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -1,10 +1,19 @@
import { CCStoreAutoSaver } from "./autoSaver";
-import { CCComponentStore } from "./component";
-import { CCComponentPinStore } from "./componentPin";
-import { CCConnectionStore } from "./connection";
+import {
+ CCComponentStore,
+ ccComponentStoreChangeEventTypes,
+} from "./component";
+import {
+ CCComponentPinStore,
+ ccComponentPinStoreChangeEventTypes,
+} from "./componentPin";
+import {
+ CCConnectionStore,
+ ccConnectionStoreChangeEventTypes,
+} from "./connection";
import * as intrinsics from "./intrinsics/definitions";
import { CCNodeStore } from "./node";
-import { CCNodePinStore } from "./nodePin";
+import { CCNodePinStore, ccNodePinStoreChangeEventTypes } from "./nodePin";
import TransactionManager from "./transaction";
/**
@@ -25,6 +34,17 @@ export default class CCStore {
autoSaver: CCStoreAutoSaver;
+ #revision = 0;
+
+ /**
+ * How many circuit changes the store has seen. Anything derived from the circuit, such
+ * as a simulation netlist, can be cached against this number and rebuilt once it
+ * changes. Moving a node does not change the circuit, so it does not count.
+ */
+ get revision(): number {
+ return this.#revision;
+ }
+
/**
* Constructor of CCStore
* @param rootComponent root component
@@ -45,6 +65,31 @@ export default class CCStore {
this.componentPins.register(pin);
}
}
+
+ const countRevision = () => {
+ this.#revision += 1;
+ };
+ for (const eventType of ccComponentStoreChangeEventTypes) {
+ this.components.on(eventType, countRevision);
+ }
+ for (const eventType of ccComponentPinStoreChangeEventTypes) {
+ this.componentPins.on(eventType, countRevision);
+ }
+ // `didUpdate` of the node store also fires for position changes, which the circuit
+ // does not depend on, so only the config is watched here.
+ for (const eventType of [
+ "didRegister",
+ "didUnregister",
+ "didUpdateConfig",
+ ] as const) {
+ this.nodes.on(eventType, countRevision);
+ }
+ for (const eventType of ccNodePinStoreChangeEventTypes) {
+ this.nodePins.on(eventType, countRevision);
+ }
+ for (const eventType of ccConnectionStoreChangeEventTypes) {
+ this.connections.on(eventType, countRevision);
+ }
}
mount() {
diff --git a/src/store/intrinsics/base.ts b/src/store/intrinsics/base.ts
index d676c26..36b4344 100644
--- a/src/store/intrinsics/base.ts
+++ b/src/store/intrinsics/base.ts
@@ -1,34 +1,12 @@
import { mapValues } from "es-toolkit";
import type { CCComponent, CCComponentId } from "../component";
import type { CCComponentPin, CCComponentPinId } from "../componentPin";
-import type { CCNodeId } from "../node";
-import type { CCNodePinId } from "../nodePin";
-import type { SimulationFrame } from "../simulation";
+import type { SimulationValue } from "../simulation";
import type {
CCIntrinsicComponentSpec,
CCIntrinsicComponentType,
} from "./types";
-export type CCIntrinsicComponentShape = {
- inputShape: Record;
- outputShape: Record;
-};
-
-export type CCComponentPinInstanceShapes = {
- nodePinId: CCNodePinId;
- bitWidth: number;
-}[];
-
-export type ComponentEvaluationContext = {
- previousFrame: SimulationFrame | null;
- currentFrame: SimulationFrame;
- defaultBitWidth: number;
-};
-
-export type Context = {
- componentId: CCComponentId;
-};
-
/**
* How the bit width of an intrinsic component pin is determined.
* - `inferred` — inferred from the pins it is transitively connected to.
@@ -62,14 +40,48 @@ type IntrinsicComponentPinAttributes = {
export type RegisteredIntrinsicComponentPinAttributes =
IntrinsicComponentPinAttributes & { key: string };
-type IntrinsicComponentEvaluationFunction<
+/**
+ * The values of the pins of one intrinsic node, grouped by pin key. A pin key can have
+ * more than one node pin when it is split, so each key holds the values in pin order.
+ */
+export type IntrinsicPinValues = Record<
+ Key,
+ SimulationValue[]
+>;
+
+/** What an intrinsic node needs to know about itself besides its input values. */
+export type IntrinsicEvaluationEnvironment<
Spec extends CCIntrinsicComponentSpec,
-> = (
- context: ComponentEvaluationContext,
- nodeId: CCNodeId,
- shape: CCIntrinsicComponentShape,
- config: Spec["config"],
-) => boolean;
+> = {
+ /** bit widths of the output pins, grouped like {@link IntrinsicPinValues} */
+ outputBitWidths: Record;
+ config: Spec["config"];
+};
+
+/**
+ * How the outputs of an intrinsic node are computed in one time step.
+ * - `combinational` — from the inputs of the same time step.
+ * - `sequential` — from the inputs of the previous time step (`null` in the first one).
+ * It does not see the current inputs at all, because it is evaluated before they are
+ * known. That is what lets a circuit contain a feedback loop through such a node.
+ */
+export type IntrinsicComponentBehavior<
+ Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec,
+> =
+ | {
+ kind: "combinational";
+ evaluate(
+ inputs: IntrinsicPinValues,
+ environment: IntrinsicEvaluationEnvironment,
+ ): IntrinsicPinValues;
+ }
+ | {
+ kind: "sequential";
+ evaluate(
+ previousInputs: IntrinsicPinValues | null,
+ environment: IntrinsicEvaluationEnvironment,
+ ): IntrinsicPinValues;
+ };
type Props = {
type: CCIntrinsicComponentType;
@@ -77,7 +89,11 @@ type Props = {
in: Record>;
out: Record>;
initialConfig: Spec["config"];
- evaluate: IntrinsicComponentEvaluationFunction;
+ /**
+ * null for `Input` and `Output`, which only carry a value across a component
+ * boundary and are resolved away before the simulation evaluates anything
+ */
+ behavior: IntrinsicComponentBehavior | null;
};
export class IntrinsicComponentDefinition<
Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec,
@@ -90,7 +106,8 @@ export class IntrinsicComponentDefinition<
readonly inputPin: Record;
readonly outputPin: Record;
readonly initialConfig: Spec["config"];
- readonly evaluate: IntrinsicComponentEvaluationFunction;
+ /** @see {@link Props.behavior} */
+ readonly behavior: IntrinsicComponentBehavior | null;
private static _lastIndex = 0;
@@ -114,7 +131,7 @@ export class IntrinsicComponentDefinition<
name: this.name,
};
IntrinsicComponentDefinition._byId.set(this.id, this);
- this.evaluate = props.evaluate;
+ this.behavior = props.behavior;
this.inputPin = mapValues(props.in, (attributes, key) =>
this._registerPin("input", key, attributes),
diff --git a/src/store/intrinsics/definitions.test.ts b/src/store/intrinsics/definitions.test.ts
new file mode 100644
index 0000000..880d91b
--- /dev/null
+++ b/src/store/intrinsics/definitions.test.ts
@@ -0,0 +1,231 @@
+import nullthrows from "nullthrows";
+import invariant from "tiny-invariant";
+import { describe, expect, it } from "vitest";
+import { testBits } from "../testUtils";
+import type {
+ IntrinsicComponentDefinition,
+ IntrinsicEvaluationEnvironment,
+ IntrinsicPinValues,
+} from "./base";
+import {
+ aggregate,
+ and,
+ and_n,
+ broadcast,
+ const_,
+ decompose,
+ definitions,
+ false_,
+ flipflop,
+ input,
+ not,
+ or,
+ or_n,
+ output,
+ true_,
+ xor,
+} from "./definitions";
+import type { CCIntrinsicComponentSpec } from "./types";
+
+function evaluateCombinational(
+ definition: IntrinsicComponentDefinition,
+ inputs: IntrinsicPinValues,
+ environment: IntrinsicEvaluationEnvironment,
+): IntrinsicPinValues {
+ const behavior = nullthrows(definition.behavior);
+ invariant(behavior.kind === "combinational");
+ return behavior.evaluate(inputs, environment);
+}
+
+function evaluateSequential(
+ definition: IntrinsicComponentDefinition,
+ previousInputs: IntrinsicPinValues | null,
+ environment: IntrinsicEvaluationEnvironment,
+): IntrinsicPinValues {
+ const behavior = nullthrows(definition.behavior);
+ invariant(behavior.kind === "sequential");
+ return behavior.evaluate(previousInputs, environment);
+}
+
+describe("bitwise operators", () => {
+ it("applies Not to each bit", () => {
+ expect(
+ evaluateCombinational(
+ not,
+ { In: [testBits(0b0110, 4)] },
+ { outputBitWidths: { Out: [4] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(0b1001, 4)] });
+ });
+
+ it.each([
+ { definition: and, expected: 0b1000 },
+ { definition: or, expected: 0b1110 },
+ { definition: xor, expected: 0b0110 },
+ ])(
+ "applies $definition.name to each pair of bits",
+ ({ definition, expected }) => {
+ expect(
+ evaluateCombinational(
+ definition,
+ { A: [testBits(0b1100, 4)], B: [testBits(0b1010, 4)] },
+ { outputBitWidths: { Out: [4] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(expected, 4)] });
+ },
+ );
+
+ it("rejects operands of different widths", () => {
+ expect(() =>
+ evaluateCombinational(
+ and,
+ { A: [testBits(0, 4)], B: [testBits(0, 2)] },
+ { outputBitWidths: { Out: [4] }, config: null },
+ ),
+ ).toThrow();
+ });
+});
+
+describe("constants", () => {
+ it.each([
+ { definition: true_, bit: true },
+ { definition: false_, bit: false },
+ ])(
+ "fills the output of $definition.name to its bit width",
+ ({ definition, bit }) => {
+ expect(
+ evaluateCombinational(
+ definition,
+ {},
+ { outputBitWidths: { Out: [3] }, config: null },
+ ),
+ ).toEqual({ Out: [[bit, bit, bit]] });
+ },
+ );
+
+ it("outputs the data configured on a Const node", () => {
+ const data = testBits(0b101, 3);
+ expect(
+ evaluateCombinational(
+ const_,
+ {},
+ { outputBitWidths: { Out: [3] }, config: { mode: "binary", data } },
+ ),
+ ).toEqual({ Out: [data] });
+ });
+});
+
+describe("bit width conversions", () => {
+ it("concatenates the split input pins of Aggregate in pin order", () => {
+ expect(
+ evaluateCombinational(
+ aggregate,
+ { In: [testBits(0b10, 2), testBits(0b011, 3)] },
+ { outputBitWidths: { Out: [5] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(0b10011, 5)] });
+ });
+
+ it("splits the input of Decompose by the widths of its output pins", () => {
+ expect(
+ evaluateCombinational(
+ decompose,
+ { In: [testBits(0b10011, 5)] },
+ { outputBitWidths: { Out: [2, 3] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(0b10, 2), testBits(0b011, 3)] });
+ });
+
+ it("repeats the input bit of Broadcast to the output width", () => {
+ expect(
+ evaluateCombinational(
+ broadcast,
+ { In: [[true]] },
+ { outputBitWidths: { Out: [4] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(0b1111, 4)] });
+ });
+
+ it("rejects a Broadcast input wider than one bit", () => {
+ expect(() =>
+ evaluateCombinational(
+ broadcast,
+ { In: [testBits(0b11, 2)] },
+ { outputBitWidths: { Out: [4] }, config: null },
+ ),
+ ).toThrow();
+ });
+});
+
+describe("reductions", () => {
+ it("reduces every bit of every input pin of AndN", () => {
+ const environment = { outputBitWidths: { Out: [1] }, config: null };
+ expect(
+ evaluateCombinational(
+ and_n,
+ { In: [testBits(0b11, 2), [true]] },
+ environment,
+ ),
+ ).toEqual({ Out: [[true]] });
+ expect(
+ evaluateCombinational(
+ and_n,
+ { In: [testBits(0b11, 2), [false]] },
+ environment,
+ ),
+ ).toEqual({ Out: [[false]] });
+ });
+
+ it("reduces every bit of every input pin of OrN", () => {
+ const environment = { outputBitWidths: { Out: [1] }, config: null };
+ expect(
+ evaluateCombinational(
+ or_n,
+ { In: [testBits(0b00, 2), [true]] },
+ environment,
+ ),
+ ).toEqual({ Out: [[true]] });
+ expect(
+ evaluateCombinational(
+ or_n,
+ { In: [testBits(0b00, 2), [false]] },
+ environment,
+ ),
+ ).toEqual({ Out: [[false]] });
+ });
+});
+
+describe("FlipFlop", () => {
+ it("is the only sequential intrinsic", () => {
+ const sequential = Object.values(definitions).filter(
+ (definition) => definition.behavior?.kind === "sequential",
+ );
+ expect(sequential).toEqual([flipflop]);
+ });
+
+ it("starts from zero before anything has been fed to it", () => {
+ expect(
+ evaluateSequential(flipflop, null, {
+ outputBitWidths: { Out: [3] },
+ config: null,
+ }),
+ ).toEqual({ Out: [testBits(0, 3)] });
+ });
+
+ it("outputs its input of the previous time step", () => {
+ expect(
+ evaluateSequential(
+ flipflop,
+ { In: [testBits(0b101, 3)] },
+ { outputBitWidths: { Out: [3] }, config: null },
+ ),
+ ).toEqual({ Out: [testBits(0b101, 3)] });
+ });
+});
+
+describe("component boundaries", () => {
+ it("leaves Input and Output without a behavior to evaluate", () => {
+ expect(input.behavior).toBeNull();
+ expect(output.behavior).toBeNull();
+ });
+});
diff --git a/src/store/intrinsics/definitions.ts b/src/store/intrinsics/definitions.ts
index 441f033..2ceeb34 100644
--- a/src/store/intrinsics/definitions.ts
+++ b/src/store/intrinsics/definitions.ts
@@ -16,6 +16,12 @@ import {
ccIntrinsicComponentTypes,
} from "./types";
+/** The one entry of a pin key that cannot be split into multiple node pins. */
+function single(values: readonly T[]): T {
+ invariant(values.length === 1, `Expected a single pin, got ${values.length}`);
+ return nullthrows(values[0]);
+}
+
function createUnaryOperator(
type: CCIntrinsicComponentType,
name: string,
@@ -28,22 +34,9 @@ function createUnaryOperator(
in: { In: { name: "In", bitWidthPolicy: { type: "inferred" } } },
out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } },
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- invariant(inputShape[0] && !inputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId);
- if (!inputValue || !nodePinIdToValue) {
- return false;
- }
- const outputValue = Array.from({ length: inputValue.length }, (_, i) =>
- evaluate(nullthrows(inputValue[i])),
- );
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
-
- nodePinIdToValue.set(outputShape[0].nodePinId, outputValue);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }) => ({ Out: [single(In).map((a) => evaluate(a))] }),
},
},
);
@@ -64,37 +57,17 @@ function createBinaryOperator(
},
out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } },
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShapeA = shape.inputShape.A;
- const inputShapeB = shape.inputShape.B;
- invariant(inputShapeA[0] && !inputShapeA[1]);
- invariant(inputShapeB[0] && !inputShapeB[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const inputValueA = nodePinIdToValue?.get(inputShapeA[0].nodePinId);
- const inputValueB = nodePinIdToValue?.get(inputShapeB[0].nodePinId);
- if (!inputValueA || !inputValueB || !nodePinIdToValue) {
- return false;
- }
- invariant(
- inputValueA.length === inputValueB.length,
- "Input lengths must match (name: " +
- name +
- ", nodeId: " +
- nodeId +
- ", inputValueA: " +
- inputValueA +
- ", inputValueB: " +
- inputValueB +
- ")",
- );
- const outputValue = Array.from({ length: inputValueA.length }, (_, i) =>
- evaluate(nullthrows(inputValueA[i]), nullthrows(inputValueB[i])),
- );
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
-
- nodePinIdToValue.set(outputShape[0].nodePinId, outputValue);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ A, B }) => {
+ const a = single(A);
+ const b = single(B);
+ invariant(
+ a.length === b.length,
+ `Input lengths must match (A: ${a.length}, B: ${b.length})`,
+ );
+ return { Out: [a.map((bit, i) => evaluate(bit, nullthrows(b[i])))] };
+ },
},
},
);
@@ -112,14 +85,11 @@ function createNullaryOperator(
in: {},
out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } },
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- if (!nodePinIdToValue) return false;
- const outputValue = new Array(outputShape[0].bitWidth).fill(value);
- nodePinIdToValue.set(outputShape[0].nodePinId, outputValue);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: (_, { outputBitWidths }) => ({
+ Out: [new Array(single(outputBitWidths.Out)).fill(value)],
+ }),
},
},
);
@@ -163,9 +133,7 @@ export const input =
in: {},
out: { Out: { name: "In", bitWidthPolicy: { type: "inferred" } } },
initialConfig: null,
- evaluate: (_context, _nodeId, _shape) => {
- return true;
- },
+ behavior: null,
});
export const output =
@@ -175,9 +143,7 @@ export const output =
in: { In: { name: "Out", bitWidthPolicy: { type: "inferred" } } },
out: {},
initialConfig: null,
- evaluate: (_context, _nodeId, _shape) => {
- return true;
- },
+ behavior: null,
});
export const aggregate =
@@ -202,21 +168,9 @@ export const aggregate =
},
},
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const inputValues = inputShape.map((s) =>
- nodePinIdToValue?.get(s.nodePinId),
- );
- if (inputValues.some((v) => !v) || !nodePinIdToValue) {
- return false;
- }
- const outputValue = inputValues.flatMap((v) => nullthrows(v));
- nodePinIdToValue.set(
- nullthrows(shape.outputShape.Out[0]?.nodePinId),
- outputValue,
- );
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }) => ({ Out: [In.flat()] }),
},
});
@@ -244,24 +198,19 @@ export const decompose =
},
},
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- invariant(inputShape[0] && !inputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const outputShape = shape.outputShape.Out;
- const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId);
- if (!inputValue || !nodePinIdToValue) {
- return false;
- }
- let currentIndex = 0;
- for (const shape of outputShape) {
- nodePinIdToValue.set(
- shape.nodePinId,
- inputValue.slice(currentIndex, currentIndex + shape.bitWidth),
- );
- currentIndex += shape.bitWidth;
- }
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }, { outputBitWidths }) => {
+ const value = single(In);
+ let offset = 0;
+ return {
+ Out: outputBitWidths.Out.map((bitWidth) => {
+ const part = value.slice(offset, offset + bitWidth);
+ offset += bitWidth;
+ return part;
+ }),
+ };
+ },
},
});
@@ -282,22 +231,11 @@ export const and_n =
},
},
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const inputValues = inputShape.map((s) =>
- nodePinIdToValue?.get(s.nodePinId),
- );
- if (inputValues.some((v) => !v) || !nodePinIdToValue) {
- return false;
- }
- const outputValue = inputValues.every((v) =>
- nullthrows(v).every(Boolean),
- );
- nodePinIdToValue.set(nullthrows(shape.outputShape.Out[0]?.nodePinId), [
- outputValue,
- ]);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }) => ({
+ Out: [[In.every((value) => value.every(Boolean))]],
+ }),
},
});
@@ -318,20 +256,11 @@ export const or_n =
},
},
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const inputValues = inputShape.map((s) =>
- nodePinIdToValue?.get(s.nodePinId),
- );
- if (inputValues.some((v) => !v) || !nodePinIdToValue) {
- return false;
- }
- const outputValue = inputValues.some((v) => nullthrows(v).some(Boolean));
- nodePinIdToValue.set(nullthrows(shape.outputShape.Out[0]?.nodePinId), [
- outputValue,
- ]);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }) => ({
+ Out: [[In.some((value) => value.some(Boolean))]],
+ }),
},
});
@@ -352,24 +281,14 @@ export const broadcast =
},
},
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- invariant(inputShape[0] && !inputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
- const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId);
- if (!inputValue || !nodePinIdToValue) {
- return false;
- }
- invariant(inputValue.length === 1);
- nodePinIdToValue.set(
- outputShape[0].nodePinId,
- Array.from({ length: outputShape[0].bitWidth }, () =>
- nullthrows(inputValue[0]),
- ),
- );
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: ({ In }, { outputBitWidths }) => {
+ const bit = single(single(In));
+ return {
+ Out: [new Array(single(outputBitWidths.Out)).fill(bit)],
+ };
+ },
},
});
@@ -380,22 +299,16 @@ export const flipflop =
in: { In: { name: "In", bitWidthPolicy: { type: "inferred" } } },
out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } },
initialConfig: null,
- evaluate: (context, nodeId, shape) => {
- const inputShape = shape.inputShape.In;
- invariant(inputShape[0] && !inputShape[1]);
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- const previousValue =
- context.previousFrame?.nodes
- .get(nodeId)
- ?.pins.get(inputShape[0].nodePinId) ??
- new Array(inputShape[0].bitWidth).fill(false);
- if (!nodePinIdToValue) {
- return false;
- }
- nodePinIdToValue.set(outputShape[0].nodePinId, previousValue);
- return true;
+ behavior: {
+ kind: "sequential",
+ // Nothing has been fed to it before the first time step, so it starts from zero
+ evaluate: (previousInputs, { outputBitWidths }) => ({
+ Out: [
+ previousInputs
+ ? single(previousInputs.In)
+ : new Array(single(outputBitWidths.Out)).fill(false),
+ ],
+ }),
},
});
@@ -418,7 +331,8 @@ export const display =
resolution: { x: 20, y: 15 },
pixelOrder: "row-major",
},
- evaluate: () => true,
+ // The renderer reads the pixels from the input pin; there is nothing to compute
+ behavior: { kind: "combinational", evaluate: () => ({}) },
});
export const const_ =
@@ -436,13 +350,9 @@ export const const_ =
},
},
initialConfig: { mode: "binary", data: new Array(8).fill(false) },
- evaluate: (context, nodeId, shape, config) => {
- const outputShape = shape.outputShape.Out;
- invariant(outputShape[0] && !outputShape[1]);
- const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins;
- if (!nodePinIdToValue) return false;
- nodePinIdToValue.set(outputShape[0].nodePinId, config.data);
- return true;
+ behavior: {
+ kind: "combinational",
+ evaluate: (_, { config }) => ({ Out: [config.data] }),
},
});
diff --git a/src/store/netlist.ts b/src/store/netlist.ts
new file mode 100644
index 0000000..120b81d
--- /dev/null
+++ b/src/store/netlist.ts
@@ -0,0 +1,528 @@
+/**
+ * Flattening a component for simulation: the hierarchy is resolved into the intrinsic
+ * nodes the component is built from, and the pins carrying the same value are grouped
+ * into nets.
+ *
+ * - A **terminal** is one pin of one node instance. A component can be used more than
+ * once, so the node pins inside it exist once per use and are only unique together
+ * with the path of node ids leading from the simulated component down to the node the
+ * pin belongs to. {@link simulationTerminalKey} builds the key `path#nodePinId`.
+ * - A **net** is the terminals that always carry the same value: the ones tied together
+ * by a connection or by a component boundary.
+ * - The **driver** is the one terminal of a net that produces the value, which is either
+ * an output pin of an intrinsic node or the output pin of an `Input` node of the
+ * simulated component itself (a value supplied from the outside). Every other terminal
+ * of the net only receives the value, so the driver identifies the net.
+ *
+ * In `Const -> Delay(D -> FlipFlop -> Q)`, the value of the constant is one net of four
+ * terminals:
+ *
+ * ```
+ * Const#Out the driver, an output pin of an intrinsic node
+ * DelayNode#D the pin of the node using the component
+ * DelayNode/Input#Out the `Input` node inside the component
+ * DelayNode/FlipFlop#In the pin consuming the value
+ * ```
+ *
+ * `Input` and `Output` nodes only carry a value across a component boundary, so they are
+ * resolved away while flattening and never become an instance to evaluate.
+ */
+
+import nullthrows from "nullthrows";
+import invariant from "tiny-invariant";
+import type { Tagged } from "type-fest";
+import type CCStore from ".";
+import type { CCComponentId } from "./component";
+import type { CCComponentPinId } from "./componentPin";
+import type {
+ IntrinsicComponentBehavior,
+ IntrinsicComponentDefinition,
+} from "./intrinsics/base";
+import { definitionByComponentId } from "./intrinsics/definitions";
+import { ccIntrinsicComponentTypes } from "./intrinsics/types";
+import type { CCNode, CCNodeId } from "./node";
+import type { CCNodePinId } from "./nodePin";
+
+/** Identity of a net: the key of the terminal driving it. */
+export type SimulationNetId = Tagged;
+
+/** Identity of a terminal: the path of its node instance plus its node pin. */
+export type SimulationTerminalKey = Tagged;
+
+/**
+ * Key of the pin `nodePinId` of the node instance at `path`.
+ * @param path node ids from the simulated component down to the node of the pin
+ * @param nodePinId id of the node pin
+ */
+export function simulationTerminalKey(
+ path: readonly CCNodeId[],
+ nodePinId: CCNodePinId,
+): SimulationTerminalKey {
+ return `${path.join("/")}#${nodePinId}` as SimulationTerminalKey;
+}
+
+/** One pin of an intrinsic node instance, resolved for simulation. */
+export type SimulationPinInstance = {
+ readonly nodePinId: CCNodePinId;
+ /** net the pin is attached to */
+ readonly netId: SimulationNetId;
+ /** bit width of the pin in this instance */
+ readonly bitWidth: number;
+};
+
+/**
+ * One intrinsic node, instantiated at a concrete place of the component hierarchy.
+ * `Input` and `Output` nodes are not instances: they only pass values across a
+ * component boundary, so they are resolved away while building the netlist.
+ */
+export type SimulationInstance = {
+ /** node ids from the simulated component down to this node */
+ readonly path: readonly CCNodeId[];
+ readonly nodeId: CCNodeId;
+ readonly definition: IntrinsicComponentDefinition;
+ readonly config: CCNode["config"];
+ /** pins grouped by the pin key of the definition (e.g. `A`, `In`), ordered by pin order */
+ readonly inputShape: Record;
+ readonly outputShape: Record;
+ /** how the outputs are computed, taken from the definition */
+ readonly behavior: IntrinsicComponentBehavior;
+};
+
+/**
+ * The flattened form of a component: every intrinsic node of the component and of the
+ * components it uses, in an order where each instance is evaluated after the instances
+ * driving its inputs.
+ */
+export type SimulationNetlist = {
+ readonly componentId: CCComponentId;
+ /** revision of the store this netlist was built from */
+ readonly revision: number;
+ readonly instances: readonly SimulationInstance[];
+ /** net driven by each input pin of the simulated component */
+ readonly netByInputComponentPin: ReadonlyMap<
+ CCComponentPinId,
+ SimulationNetId
+ >;
+ /** net of each node pin of the nodes of the simulated component itself */
+ readonly netByNodePin: ReadonlyMap;
+ /**
+ * Net of the pins that were resolved while flattening: every pin of the nodes of the
+ * simulated component and of the intrinsic instances inside it, plus the pins of the
+ * `Input`, `Output` and component nodes on the way between them.
+ */
+ readonly netByTerminal: ReadonlyMap;
+};
+
+/**
+ * Thrown when a value would have to be known before it can be computed. Such a loop
+ * can only be resolved by a node that holds its value for one time step, so the fix is
+ * to insert a `FlipFlop` somewhere along the reported path.
+ */
+export class SimulationCombinationalLoopError extends Error {
+ constructor(readonly loop: readonly string[]) {
+ super(
+ `Combinational loop detected: ${loop.join(" -> ")}. Insert a FlipFlop to break it.`,
+ );
+ this.name = "SimulationCombinationalLoopError";
+ }
+}
+
+/** Thrown when an input pin has nothing connected to it. */
+export class SimulationUnconnectedPinError extends Error {
+ constructor(
+ readonly nodeId: CCNodeId,
+ readonly nodePinId: CCNodePinId,
+ ) {
+ super(`Input pin ${nodePinId} of node ${nodeId} is not connected.`);
+ this.name = "SimulationUnconnectedPinError";
+ }
+}
+
+/**
+ * The bit width the pins of a node instance fall back to when their own width is not
+ * fixed. It is inherited from the pin of the node whose width the surrounding circuit
+ * has already fixed, which is how a component written for one bit width can be reused
+ * for another.
+ */
+function resolveInstanceDefaultBitWidth(
+ store: CCStore,
+ node: CCNode,
+ parentDefaultBitWidth: number,
+): number {
+ for (const nodePin of store.nodePins.getManyByNodeId(node.id)) {
+ const componentPinBitWidthStatus =
+ store.componentPins.getComponentPinBitWidthStatus(nodePin.componentPinId);
+ if (
+ !componentPinBitWidthStatus.isFixed &&
+ componentPinBitWidthStatus.fixMode === "automatic"
+ ) {
+ const nodePinBitWidthStatus = store.nodePins.getNodePinBitWidthStatus(
+ nodePin.id,
+ );
+ if (nodePinBitWidthStatus.isFixed) {
+ return nodePinBitWidthStatus.bitWidth;
+ }
+ }
+ }
+ return parentDefaultBitWidth;
+}
+
+/**
+ * Flatten `componentId` into intrinsic node instances and the nets between them.
+ * @param store store to read the circuit from
+ * @param componentId component to flatten
+ * @param defaultBitWidth bit width of the pins of the component whose width is not fixed
+ * @returns the flattened netlist, ready to be evaluated time step by time step
+ */
+export function buildSimulationNetlist(
+ store: CCStore,
+ componentId: CCComponentId,
+ defaultBitWidth = 1,
+): SimulationNetlist {
+ const netByTerminal = new Map();
+ const instances: SimulationInstance[] = [];
+
+ /**
+ * Walk from a pin to the pin driving it, hopping over the `Input` and `Output` nodes
+ * and the component nodes in between, and remember the net for every pin on the way.
+ */
+ const resolveNet = (
+ startPath: readonly CCNodeId[],
+ startNodePinId: CCNodePinId,
+ ): SimulationNetId => {
+ const visited: SimulationTerminalKey[] = [];
+ const visitedKeys = new Set();
+ /** the nodes walked through so far, for the error message of a loop */
+ const visitedNodes: string[] = [];
+ let path = startPath;
+ let nodePinId = startNodePinId;
+ let netId: SimulationNetId | null = null;
+ while (netId === null) {
+ const terminal = simulationTerminalKey(path, nodePinId);
+ const known = netByTerminal.get(terminal);
+ if (known) {
+ netId = known;
+ break;
+ }
+ if (visitedKeys.has(terminal)) {
+ throw new SimulationCombinationalLoopError(visitedNodes);
+ }
+ visited.push(terminal);
+ visitedKeys.add(terminal);
+
+ const nodePin = nullthrows(store.nodePins.get(nodePinId));
+ const node = nullthrows(store.nodes.get(nodePin.nodeId));
+ const componentPin = nullthrows(
+ store.componentPins.get(nodePin.componentPinId),
+ );
+ const component = nullthrows(store.components.get(node.componentId));
+ const walkedNode = `${component.name}(${path.join("/")})`;
+ if (visitedNodes.at(-1) !== walkedNode) visitedNodes.push(walkedNode);
+
+ if (componentPin.type === "input") {
+ // An input pin is driven by a connection inside the component it sits in
+ const connection = store.connections
+ .getConnectionsByNodePinId(nodePinId)
+ .find((candidate) => candidate.to === nodePinId);
+ if (!connection) {
+ throw new SimulationUnconnectedPinError(node.id, nodePinId);
+ }
+ const sourceNodePin = nullthrows(store.nodePins.get(connection.from));
+ path = [...path.slice(0, -1), sourceNodePin.nodeId];
+ nodePinId = sourceNodePin.id;
+ continue;
+ }
+
+ if (
+ component.intrinsicType === ccIntrinsicComponentTypes.INPUT &&
+ path.length > 1
+ ) {
+ // Fed from the pin of the node that uses the component this node sits in
+ const boundaryPin = nullthrows(
+ store.componentPins.getByImplementation(nodePinId),
+ `Input node ${node.id} does not expose a component pin`,
+ );
+ const outerNodeId = nullthrows(path[path.length - 2]);
+ const outerNodePin = nullthrows(
+ store.nodePins
+ .getManyByNodeId(outerNodeId)
+ .find((candidate) => candidate.componentPinId === boundaryPin.id),
+ `Node ${outerNodeId} is missing the pin of ${boundaryPin.id}`,
+ );
+ path = path.slice(0, -1);
+ nodePinId = outerNodePin.id;
+ continue;
+ }
+
+ if (!component.intrinsicType) {
+ // Driven from inside the component, by the node implementing the pin
+ const implementation = nullthrows(
+ componentPin.implementation,
+ `Pin ${componentPin.id} of a user-defined component has no implementation`,
+ );
+ const innerNodePin = nullthrows(store.nodePins.get(implementation));
+ path = [...path, innerNodePin.nodeId];
+ nodePinId = innerNodePin.id;
+ continue;
+ }
+
+ // An output pin of an intrinsic node, or of an `Input` node of the simulated
+ // component itself: the value originates here, so this pin drives the net.
+ netId = terminal as string as SimulationNetId;
+ }
+ for (const terminal of visited) netByTerminal.set(terminal, netId);
+ return netId;
+ };
+
+ /** Resolve the pins of one intrinsic node instance, grouped by definition pin key. */
+ const resolveShape = (
+ path: readonly CCNodeId[],
+ nodeId: CCNodeId,
+ pinByKey: Record,
+ instanceDefaultBitWidth: number,
+ ): Record => {
+ const shape: Record = {};
+ for (const [key, componentPin] of Object.entries(pinByKey)) {
+ shape[key] = store.nodePins
+ .getManyByNodeIdAndComponentPinId(nodeId, componentPin.id)
+ .toSorted((a, b) => a.order - b.order)
+ .map((nodePin) => {
+ const bitWidthStatus = store.nodePins.getNodePinBitWidthStatus(
+ nodePin.id,
+ );
+ return {
+ nodePinId: nodePin.id,
+ netId: resolveNet(path, nodePin.id),
+ bitWidth: bitWidthStatus.isFixed
+ ? bitWidthStatus.bitWidth
+ : instanceDefaultBitWidth,
+ };
+ });
+ }
+ return shape;
+ };
+
+ const visit = (
+ path: readonly CCNodeId[],
+ ownerComponentId: CCComponentId,
+ instanceDefaultBitWidth: number,
+ componentStack: readonly CCComponentId[],
+ ) => {
+ invariant(
+ !componentStack.includes(ownerComponentId),
+ `Component ${ownerComponentId} contains itself`,
+ );
+ for (const node of store.nodes.getManyByParentComponentId(
+ ownerComponentId,
+ )) {
+ const nodePath = [...path, node.id];
+ for (const nodePin of store.nodePins.getManyByNodeId(node.id)) {
+ const componentPin = nullthrows(
+ store.componentPins.get(nodePin.componentPinId),
+ );
+ if (
+ componentPin.type === "input" &&
+ store.connections.getConnectionsByNodePinId(nodePin.id).length === 0
+ ) {
+ throw new SimulationUnconnectedPinError(node.id, nodePin.id);
+ }
+ }
+
+ const component = nullthrows(store.components.get(node.componentId));
+ if (!component.intrinsicType) {
+ visit(
+ nodePath,
+ node.componentId,
+ resolveInstanceDefaultBitWidth(store, node, instanceDefaultBitWidth),
+ [...componentStack, ownerComponentId],
+ );
+ continue;
+ }
+ if (
+ component.intrinsicType === ccIntrinsicComponentTypes.INPUT ||
+ component.intrinsicType === ccIntrinsicComponentTypes.OUTPUT
+ ) {
+ continue; // resolved away as part of the nets crossing the boundary
+ }
+
+ const definition = nullthrows(
+ definitionByComponentId.get(node.componentId),
+ `Intrinsic component ${node.componentId} has no definition`,
+ );
+ const behavior = nullthrows(
+ definition.behavior,
+ `Intrinsic component ${definition.name} cannot be evaluated`,
+ );
+ instances.push({
+ path: nodePath,
+ nodeId: node.id,
+ definition,
+ config: node.config,
+ inputShape: resolveShape(
+ nodePath,
+ node.id,
+ definition.inputPin,
+ instanceDefaultBitWidth,
+ ),
+ outputShape: resolveShape(
+ nodePath,
+ node.id,
+ definition.outputPin,
+ instanceDefaultBitWidth,
+ ),
+ behavior,
+ });
+ }
+ };
+ visit([], componentId, defaultBitWidth, []);
+
+ // Nets of the pins the editor renders, and of the values supplied from the outside
+ const netByNodePin = new Map();
+ for (const node of store.nodes.getManyByParentComponentId(componentId)) {
+ for (const nodePin of store.nodePins.getManyByNodeId(node.id)) {
+ netByNodePin.set(nodePin.id, resolveNet([node.id], nodePin.id));
+ }
+ }
+ const netByInputComponentPin = new Map();
+ for (const componentPin of store.componentPins.getManyByComponentId(
+ componentId,
+ )) {
+ if (componentPin.type !== "input") continue;
+ const implementation = nullthrows(
+ componentPin.implementation,
+ `Pin ${componentPin.id} of a simulated component has no implementation`,
+ );
+ const nodePin = nullthrows(store.nodePins.get(implementation));
+ netByInputComponentPin.set(
+ componentPin.id,
+ resolveNet([nodePin.nodeId], nodePin.id),
+ );
+ }
+
+ return {
+ componentId,
+ revision: store.revision,
+ instances: sortInstancesByDependency(instances),
+ netByInputComponentPin,
+ netByNodePin,
+ netByTerminal,
+ };
+}
+
+/** Every pin of a shape, with the grouping by pin key flattened away. */
+export function simulationPinsOfShape(
+ shape: Record,
+): SimulationPinInstance[] {
+ return Object.values(shape).flat();
+}
+
+/** Name of an instance for error messages, e.g. `Not(nodeA/nodeB)`. */
+export function describeInstance(instance: SimulationInstance): string {
+ return `${instance.definition.name}(${instance.path.join("/")})`;
+}
+
+/**
+ * Order the instances so that each one comes after the instances driving its inputs.
+ * A sequential instance derives its outputs from the previous time step, so it does not
+ * wait for its own inputs and is what allows a circuit to contain a loop at all.
+ * @throws {SimulationCombinationalLoopError} if a loop contains no sequential instance
+ */
+function sortInstancesByDependency(
+ instances: readonly SimulationInstance[],
+): SimulationInstance[] {
+ const driverByNet = new Map();
+ for (const [index, instance] of instances.entries()) {
+ for (const pin of simulationPinsOfShape(instance.outputShape)) {
+ driverByNet.set(pin.netId, index);
+ }
+ }
+
+ /** indices of the instances waiting for each net, once per waiting pin */
+ const consumersByNet = new Map();
+ const successors = new Map();
+ const remainingDependencies = instances.map(() => 0);
+ const appendTo = (map: Map, key: Key, value: number) => {
+ const values = map.get(key);
+ if (values) values.push(value);
+ else map.set(key, [value]);
+ };
+ for (const [index, instance] of instances.entries()) {
+ // A sequential instance answers with the value of the previous time step, so it
+ // does not wait for the instances driving its inputs. That is what makes a loop
+ // containing one resolvable.
+ if (instance.behavior.kind === "sequential") continue;
+ for (const pin of simulationPinsOfShape(instance.inputShape)) {
+ const driver = driverByNet.get(pin.netId);
+ if (driver === undefined) continue; // supplied from outside the component
+ appendTo(consumersByNet, pin.netId, index);
+ appendTo(successors, driver, index);
+ remainingDependencies[index] =
+ nullthrows(remainingDependencies[index]) + 1;
+ }
+ }
+
+ const sorted: SimulationInstance[] = [];
+ const queue = instances
+ .map((_, index) => index)
+ .filter((index) => remainingDependencies[index] === 0);
+ const settled = new Set(queue);
+ while (queue.length > 0) {
+ const index = nullthrows(queue.shift());
+ const instance = nullthrows(instances[index]);
+ sorted.push(instance);
+ for (const pin of simulationPinsOfShape(instance.outputShape)) {
+ for (const consumer of consumersByNet.get(pin.netId) ?? []) {
+ const remaining = nullthrows(remainingDependencies[consumer]) - 1;
+ remainingDependencies[consumer] = remaining;
+ if (remaining === 0 && !settled.has(consumer)) {
+ settled.add(consumer);
+ queue.push(consumer);
+ }
+ }
+ }
+ }
+
+ if (sorted.length !== instances.length) {
+ const stuck = new Set(
+ instances.map((_, index) => index).filter((index) => !settled.has(index)),
+ );
+ throw new SimulationCombinationalLoopError(
+ findLoop(successors, stuck).map((index) =>
+ describeInstance(nullthrows(instances[index])),
+ ),
+ );
+ }
+ return sorted;
+}
+
+/** Find one loop among the instances that could not be ordered, for the error message. */
+function findLoop(
+ successors: ReadonlyMap,
+ stuck: ReadonlySet,
+): number[] {
+ const state = new Map();
+ const stack: number[] = [];
+ const visit = (index: number): number[] | null => {
+ state.set(index, "visiting");
+ stack.push(index);
+ for (const next of successors.get(index) ?? []) {
+ if (!stuck.has(next)) continue;
+ if (state.get(next) === "visiting") {
+ return [...stack.slice(stack.indexOf(next)), next];
+ }
+ if (!state.has(next)) {
+ const found = visit(next);
+ if (found) return found;
+ }
+ }
+ stack.pop();
+ state.set(index, "done");
+ return null;
+ };
+ for (const index of stuck) {
+ if (state.has(index)) continue;
+ const found = visit(index);
+ if (found) return found;
+ }
+ return [...stuck].slice(0, 1);
+}
diff --git a/src/store/simulation.test.ts b/src/store/simulation.test.ts
new file mode 100644
index 0000000..c1a3102
--- /dev/null
+++ b/src/store/simulation.test.ts
@@ -0,0 +1,405 @@
+import nullthrows from "nullthrows";
+import { describe, expect, it } from "vitest";
+import type CCStore from ".";
+import { and, const_, flipflop, not } from "./intrinsics/definitions";
+import {
+ SimulationCombinationalLoopError,
+ SimulationUnconnectedPinError,
+} from "./netlist";
+import simulateComponent, { type SimulationFrame } from "./simulation";
+import {
+ connectTestNodePins,
+ createTestStore,
+ registerTestComponent,
+ registerTestInputNode,
+ registerTestNode,
+ registerTestOutputNode,
+ testBits,
+ testComponentPin,
+ testNodePin,
+ testNumber,
+} from "./testUtils";
+
+/** `D` -> flip-flop -> `Q`: a component that holds its input for one time step. */
+function registerDelayComponent(store: CCStore) {
+ const component = registerTestComponent(store, "Delay");
+ const d = registerTestInputNode(store, component.id, "D");
+ const ff = registerTestNode(store, component.id, flipflop.component.id);
+ const q = registerTestOutputNode(store, component.id, "Q");
+ connectTestNodePins(
+ store,
+ component.id,
+ d.out,
+ testNodePin(store, ff, flipflop.inputPin.In),
+ );
+ connectTestNodePins(
+ store,
+ component.id,
+ testNodePin(store, ff, flipflop.outputPin.Out),
+ q.in,
+ );
+ return {
+ component,
+ ff,
+ d: testComponentPin(store, component, "D"),
+ q: testComponentPin(store, component, "Q"),
+ };
+}
+
+/** `In` -> `Out`: a component that only passes a value through. */
+function registerBufferComponent(store: CCStore) {
+ const component = registerTestComponent(store, "Buffer");
+ const into = registerTestInputNode(store, component.id, "In");
+ const out = registerTestOutputNode(store, component.id, "Out");
+ connectTestNodePins(store, component.id, into.out, out.in);
+ return {
+ component,
+ into: testComponentPin(store, component, "In"),
+ out: testComponentPin(store, component, "Out"),
+ };
+}
+
+/** Simulate `steps` time steps in a row, feeding the same input values to each. */
+function simulateSteps(
+ store: CCStore,
+ componentId: Parameters[1],
+ inputValues: Parameters[2],
+ steps: number,
+): SimulationFrame[] {
+ const frames: SimulationFrame[] = [];
+ let previous: SimulationFrame | null = null;
+ for (let timeStep = 0; timeStep < steps; timeStep += 1) {
+ previous = simulateComponent(store, componentId, inputValues, previous);
+ frames.push(previous);
+ }
+ return frames;
+}
+
+describe("simulateComponent", () => {
+ it("evaluates a combinational circuit", () => {
+ const store = createTestStore();
+ const root = registerTestComponent(store, "Root");
+ const a = registerTestInputNode(store, root.id, "A");
+ const b = registerTestInputNode(store, root.id, "B");
+ const andNode = registerTestNode(store, root.id, and.component.id);
+ const notNode = registerTestNode(store, root.id, not.component.id);
+ const out = registerTestOutputNode(store, root.id, "Out");
+ connectTestNodePins(
+ store,
+ root.id,
+ a.out,
+ testNodePin(store, andNode, and.inputPin.A),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ b.out,
+ testNodePin(store, andNode, and.inputPin.B),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, andNode, and.outputPin.Out),
+ testNodePin(store, notNode, not.inputPin.In),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, notNode, not.outputPin.Out),
+ out.in,
+ );
+
+ const nand = (left: boolean, right: boolean) =>
+ simulateComponent(
+ store,
+ root.id,
+ new Map([
+ [a.componentPinId, [left]],
+ [b.componentPinId, [right]],
+ ]),
+ ).getNodePinValue(out.in);
+ expect(nand(true, true)).toEqual([false]);
+ expect(nand(true, false)).toEqual([true]);
+ expect(nand(false, false)).toEqual([true]);
+ });
+
+ it("simulates a loop that is broken by a flip-flop inside a component", () => {
+ // Delay.Q -> Not -> Delay.D. The input of the component depends on its own output,
+ // which is only resolvable because the flip-flop inside it needs no input value.
+ const store = createTestStore();
+ const delay = registerDelayComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const delayNode = registerTestNode(store, root.id, delay.component.id);
+ const notNode = registerTestNode(store, root.id, not.component.id);
+ const q = testNodePin(store, delayNode, delay.q);
+ connectTestNodePins(
+ store,
+ root.id,
+ q,
+ testNodePin(store, notNode, not.inputPin.In),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, notNode, not.outputPin.Out),
+ testNodePin(store, delayNode, delay.d),
+ );
+
+ const frames = simulateSteps(store, root.id, new Map(), 4);
+ expect(frames.map((frame) => frame.getNodePinValue(q))).toEqual([
+ [false],
+ [true],
+ [false],
+ [true],
+ ]);
+ });
+
+ it("delays by one time step per flip-flop of a chain", () => {
+ const store = createTestStore();
+ const delay = registerDelayComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const source = registerTestInputNode(store, root.id, "In");
+ const first = registerTestNode(store, root.id, delay.component.id);
+ const second = registerTestNode(store, root.id, delay.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ source.out,
+ testNodePin(store, first, delay.d),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, first, delay.q),
+ testNodePin(store, second, delay.d),
+ );
+ const secondQ = testNodePin(store, second, delay.q);
+
+ const frames = simulateSteps(
+ store,
+ root.id,
+ new Map([[source.componentPinId, [true]]]),
+ 4,
+ );
+ expect(frames.map((frame) => frame.getNodePinValue(secondQ))).toEqual([
+ [false],
+ [false],
+ [true],
+ [true],
+ ]);
+ });
+
+ it("keeps the state of two nodes of the same component apart", () => {
+ const store = createTestStore();
+ const delay = registerDelayComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const a = registerTestInputNode(store, root.id, "A");
+ const b = registerTestInputNode(store, root.id, "B");
+ const first = registerTestNode(store, root.id, delay.component.id);
+ const second = registerTestNode(store, root.id, delay.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ a.out,
+ testNodePin(store, first, delay.d),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ b.out,
+ testNodePin(store, second, delay.d),
+ );
+
+ const frames = simulateSteps(
+ store,
+ root.id,
+ new Map([
+ [a.componentPinId, [true]],
+ [b.componentPinId, [false]],
+ ]),
+ 2,
+ );
+ const lastFrame = nullthrows(frames[1]);
+ expect(
+ lastFrame.getNodePinValue(testNodePin(store, first, delay.q)),
+ ).toEqual([true]);
+ expect(
+ lastFrame.getNodePinValue(testNodePin(store, second, delay.q)),
+ ).toEqual([false]);
+ // the same flip-flop inside the two nodes holds two different values
+ const flipFlopOut = testNodePin(store, delay.ff, flipflop.outputPin.Out);
+ expect(
+ lastFrame.getNodePinValueAt([first.id, delay.ff.id], flipFlopOut),
+ ).toEqual([true]);
+ expect(
+ lastFrame.getNodePinValueAt([second.id, delay.ff.id], flipFlopOut),
+ ).toEqual([false]);
+ });
+
+ it("takes the bit width of a component node over to the nodes inside it", () => {
+ const store = createTestStore();
+ const delay = registerDelayComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const constant = registerTestNode(store, root.id, const_.component.id, {
+ mode: "binary",
+ data: testBits(0b10100101, 8),
+ });
+ const delayNode = registerTestNode(store, root.id, delay.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, constant, const_.outputPin.Out),
+ testNodePin(store, delayNode, delay.d),
+ );
+ const q = testNodePin(store, delayNode, delay.q);
+
+ const frames = simulateSteps(store, root.id, new Map(), 2);
+ // The width of the flip-flop inside the component is not fixed by the component
+ // itself, so it has to be inherited from the node using it.
+ expect(nullthrows(frames[0]).getNodePinValue(q)).toEqual(testBits(0, 8));
+ expect(testNumber(nullthrows(frames[1]).getNodePinValue(q))).toBe(
+ 0b10100101,
+ );
+ });
+
+ it("reports a combinational loop instead of hanging", () => {
+ const store = createTestStore();
+ const root = registerTestComponent(store, "Root");
+ const first = registerTestNode(store, root.id, not.component.id);
+ const second = registerTestNode(store, root.id, not.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, first, not.outputPin.Out),
+ testNodePin(store, second, not.inputPin.In),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, second, not.outputPin.Out),
+ testNodePin(store, first, not.inputPin.In),
+ );
+
+ expect(() => simulateComponent(store, root.id, new Map())).toThrow(
+ SimulationCombinationalLoopError,
+ );
+ expect(() => simulateComponent(store, root.id, new Map())).toThrow(
+ /Insert a FlipFlop/,
+ );
+ });
+
+ it("reports a combinational loop that runs through components", () => {
+ const store = createTestStore();
+ const buffer = registerBufferComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const first = registerTestNode(store, root.id, buffer.component.id);
+ const second = registerTestNode(store, root.id, buffer.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, first, buffer.out),
+ testNodePin(store, second, buffer.into),
+ );
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, second, buffer.out),
+ testNodePin(store, first, buffer.into),
+ );
+
+ expect(() => simulateComponent(store, root.id, new Map())).toThrow(
+ SimulationCombinationalLoopError,
+ );
+ });
+
+ it("reports an input pin that is not connected", () => {
+ const store = createTestStore();
+ const root = registerTestComponent(store, "Root");
+ const andNode = registerTestNode(store, root.id, and.component.id);
+ const out = registerTestOutputNode(store, root.id, "Out");
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, andNode, and.outputPin.Out),
+ out.in,
+ );
+
+ expect(() => simulateComponent(store, root.id, new Map())).toThrow(
+ SimulationUnconnectedPinError,
+ );
+ });
+
+ it("picks up a change of the circuit", () => {
+ const store = createTestStore();
+ const root = registerTestComponent(store, "Root");
+ const constant = registerTestNode(store, root.id, const_.component.id, {
+ mode: "binary",
+ data: testBits(0b0011, 4),
+ });
+ const out = registerTestOutputNode(store, root.id, "Out");
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, constant, const_.outputPin.Out),
+ out.in,
+ );
+ expect(
+ testNumber(
+ simulateComponent(store, root.id, new Map()).getNodePinValue(out.in),
+ ),
+ ).toBe(0b0011);
+
+ // a new value of the constant has to reach the next simulation
+ store.nodes.update(constant.id, {
+ config: { mode: "binary", data: testBits(0b1010, 4) },
+ });
+ expect(
+ testNumber(
+ simulateComponent(store, root.id, new Map()).getNodePinValue(out.in),
+ ),
+ ).toBe(0b1010);
+
+ // and so has a new node
+ registerTestNode(store, root.id, and.component.id);
+ expect(() => simulateComponent(store, root.id, new Map())).toThrow(
+ SimulationUnconnectedPinError,
+ );
+ });
+
+ it("refuses to continue a frame simulated before a change", () => {
+ // The values of a frame are keyed by the nets of the netlist it was simulated
+ // from, so continuing from it after a change would restore the state of the
+ // flip-flops from nets that may no longer mean the same thing.
+ const store = createTestStore();
+ const delay = registerDelayComponent(store);
+ const root = registerTestComponent(store, "Root");
+ const source = registerTestInputNode(store, root.id, "In");
+ const delayNode = registerTestNode(store, root.id, delay.component.id);
+ connectTestNodePins(
+ store,
+ root.id,
+ source.out,
+ testNodePin(store, delayNode, delay.d),
+ );
+ const inputValues = new Map([[source.componentPinId, [true]]]);
+
+ const frame = simulateComponent(store, root.id, inputValues);
+ // the circuit stays valid, so only the revision stands in the way
+ const observer = registerTestOutputNode(store, root.id, "Out");
+ connectTestNodePins(
+ store,
+ root.id,
+ testNodePin(store, delayNode, delay.q),
+ observer.in,
+ );
+ expect(() => simulateComponent(store, root.id, inputValues, frame)).toThrow(
+ /another revision/,
+ );
+ // without the stale frame the changed circuit simulates as usual
+ expect(
+ simulateComponent(store, root.id, inputValues).getNodePinValue(
+ testNodePin(store, delayNode, delay.q),
+ ),
+ ).toEqual([false]);
+ });
+});
diff --git a/src/store/simulation.ts b/src/store/simulation.ts
index d17e82f..bcadc28 100644
--- a/src/store/simulation.ts
+++ b/src/store/simulation.ts
@@ -1,294 +1,189 @@
+import { mapValues } from "es-toolkit";
import nullthrows from "nullthrows";
import invariant from "tiny-invariant";
import type CCStore from ".";
import type { CCComponentId } from "./component";
-import type { CCComponentPin, CCComponentPinId } from "./componentPin";
+import type { CCComponentPinId } from "./componentPin";
import type {
- CCComponentPinInstanceShapes,
- CCIntrinsicComponentShape,
- ComponentEvaluationContext,
+ IntrinsicEvaluationEnvironment,
+ IntrinsicPinValues,
} from "./intrinsics/base";
-import { definitionByComponentId } from "./intrinsics/definitions";
import type { CCIntrinsicComponentSpec } from "./intrinsics/types";
+import {
+ buildSimulationNetlist,
+ describeInstance,
+ type SimulationInstance,
+ type SimulationNetId,
+ type SimulationNetlist,
+ simulationTerminalKey,
+} from "./netlist";
import type { CCNodeId } from "./node";
-import type { CCNodePin, CCNodePinId } from "./nodePin";
+import type { CCNodePinId } from "./nodePin";
export type SimulationValue = boolean[];
-export type SimulationFrame = {
- componentId: CCComponentId;
- nodes: Map<
- CCNodeId,
- {
- pins: Map;
- /** null if intrinsic */
- child: SimulationFrame | null;
- }
- >;
-};
+/**
+ * The values of one time step. Every pin that carries the same value shares one net, so
+ * a frame stores one value per net and resolves pins through the netlist.
+ */
+export class SimulationFrame {
+ constructor(
+ readonly netlist: SimulationNetlist,
+ readonly netValues: ReadonlyMap,
+ ) {}
+
+ get componentId(): CCComponentId {
+ return this.netlist.componentId;
+ }
-function createShape(
- store: CCStore,
- nodeId: CCNodeId,
- pin: Record,
- context: ComponentEvaluationContext,
-): Record {
- const node = nullthrows(store.nodes.get(nodeId));
- const { componentId } = node;
- const nodePins = store.nodePins.getManyByNodeId(nodeId);
- const componentDefinition = definitionByComponentId.get(componentId);
- invariant(componentDefinition);
- const shape: Record = {};
- for (const key in pin) {
- const componentPin = nullthrows(pin[key]);
- const inputNodePins = nodePins.filter(
- (nodePin: CCNodePin) => componentPin.id === nodePin.componentPinId,
+ /**
+ * Value at a pin of a node of the simulated component.
+ * @param nodePinId id of the node pin
+ * @returns the value, or undefined if the pin carries no value in this time step
+ */
+ getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined {
+ const netId = this.netlist.netByNodePin.get(nodePinId);
+ return netId === undefined ? undefined : this.netValues.get(netId);
+ }
+
+ /**
+ * Value at a pin of a node inside one of the components the simulated component uses.
+ * @param path node ids from the simulated component down to the node of the pin
+ * @param nodePinId id of the node pin
+ */
+ getNodePinValueAt(
+ path: readonly CCNodeId[],
+ nodePinId: CCNodePinId,
+ ): SimulationValue | undefined {
+ const netId = this.netlist.netByTerminal.get(
+ simulationTerminalKey(path, nodePinId),
);
- inputNodePins.sort((a, b) => a.order - b.order);
- shape[key] = [];
- for (const nodePin of inputNodePins) {
- const bitWidthStatus = store.nodePins.getNodePinBitWidthStatus(
- nodePin.id,
- );
- const bitWidth = !bitWidthStatus.isFixed
- ? context.defaultBitWidth
- : bitWidthStatus.bitWidth;
- shape[key].push({ nodePinId: nodePin.id, bitWidth });
- }
+ return netId === undefined ? undefined : this.netValues.get(netId);
}
- return shape;
}
-function createIntrinsicComponentShape(
- store: CCStore,
- nodeId: CCNodeId,
- context: ComponentEvaluationContext,
-): CCIntrinsicComponentShape {
- const node = nullthrows(store.nodes.get(nodeId));
- const { componentId } = node;
- const componentDefinition = definitionByComponentId.get(componentId);
- invariant(componentDefinition);
- const inputPin = componentDefinition.inputPin;
- const outputPin = componentDefinition.outputPin;
- const inputShape = createShape(store, nodeId, inputPin, context);
- const outputShape = createShape(store, nodeId, outputPin, context);
-
- return { inputShape, outputShape };
-}
+const netlistCacheByStore = new WeakMap<
+ CCStore,
+ Map
+>();
-function simulateIntrinsic(
+/**
+ * The netlist of a component, rebuilt whenever the store changed since it was built.
+ */
+function getSimulationNetlist(
store: CCStore,
- nodeId: CCNodeId,
- context: ComponentEvaluationContext,
-): boolean {
- const node = nullthrows(store.nodes.get(nodeId));
- const { componentId } = node;
- const componentDefinition = definitionByComponentId.get(componentId);
- invariant(componentDefinition);
- const shape = createIntrinsicComponentShape(store, nodeId, context);
- return componentDefinition.evaluate(context, nodeId, shape, node.config);
+ componentId: CCComponentId,
+ defaultBitWidth: number,
+): SimulationNetlist {
+ let cacheByComponent = netlistCacheByStore.get(store);
+ if (!cacheByComponent) {
+ cacheByComponent = new Map();
+ netlistCacheByStore.set(store, cacheByComponent);
+ }
+ const cacheKey = `${componentId}:${defaultBitWidth}`;
+ const cached = cacheByComponent.get(cacheKey);
+ if (cached && cached.revision === store.revision) return cached;
+ const netlist = buildSimulationNetlist(store, componentId, defaultBitWidth);
+ cacheByComponent.set(cacheKey, netlist);
+ return netlist;
}
-function simulateNode(
- store: CCStore,
- nodeId: CCNodeId,
- context: ComponentEvaluationContext,
-): boolean {
- const node = nullthrows(store.nodes.get(nodeId));
- const component = store.components.get(node.componentId);
- if (!component) throw new Error(`Component ${component} is not defined.`);
- if (component.intrinsicType) {
- return simulateIntrinsic(store, nodeId, context);
- }
- const nodePins = store.nodePins.getManyByNodeId(nodeId);
- const outerNodePinValues: Map = nullthrows(
- context.currentFrame.nodes.get(nodeId)?.pins,
+/** The values arriving at the input pins of an instance, read from the nets. */
+function collectInputs(
+ instance: SimulationInstance,
+ netValues: ReadonlyMap,
+): IntrinsicPinValues {
+ return mapValues(instance.inputShape, (pins) =>
+ pins.map((pin) =>
+ // The order guarantees that every driver ran first, so a value can only be
+ // missing when it was never supplied to an input pin of the component.
+ nullthrows(
+ netValues.get(pin.netId),
+ `No value reaches pin ${pin.nodePinId}`,
+ ),
+ ),
);
+}
- const inputValues: Map = new Map();
- // check all input values are in context
- for (const outerNodePin of nodePins) {
- const componentPin = nullthrows(
- store.componentPins.get(outerNodePin.componentPinId),
- );
- if (componentPin.type === "input") {
- if (!outerNodePinValues.has(outerNodePin.id)) {
- return false;
- }
- inputValues.set(
- componentPin.id,
- nullthrows(outerNodePinValues.get(outerNodePin.id)),
- );
- }
- }
-
- let childDefaultBitWidth = context.defaultBitWidth;
- for (const nodePin of nodePins) {
- const componentPin = nullthrows(
- store.componentPins.get(nodePin.componentPinId),
- );
- const componentPinBitWidthStatus =
- store.componentPins.getComponentPinBitWidthStatus(componentPin.id);
- if (
- !componentPinBitWidthStatus.isFixed &&
- componentPinBitWidthStatus.fixMode === "automatic"
- ) {
- const nodePinBitWidthStatus = store.nodePins.getNodePinBitWidthStatus(
- nodePin.id,
+/** Compute the outputs of one instance in the current time step. */
+function evaluateInstance(
+ instance: SimulationInstance,
+ netValues: ReadonlyMap,
+ previousFrame: SimulationFrame | null,
+): IntrinsicPinValues {
+ const { behavior } = instance;
+ const environment: IntrinsicEvaluationEnvironment =
+ {
+ outputBitWidths: mapValues(instance.outputShape, (pins) =>
+ pins.map((pin) => pin.bitWidth),
+ ),
+ config: instance.config,
+ };
+ try {
+ if (behavior.kind === "sequential") {
+ return behavior.evaluate(
+ previousFrame && collectInputs(instance, previousFrame.netValues),
+ environment,
);
- if (nodePinBitWidthStatus.isFixed) {
- childDefaultBitWidth = nodePinBitWidthStatus.bitWidth;
- break;
- }
}
- }
-
- const innerSimulationFrame = simulateComponent(
- store,
- component.id,
- inputValues,
- context.previousFrame
- ? nullthrows(context.previousFrame.nodes.get(nodeId)).child
- : null,
- childDefaultBitWidth,
- );
-
- // Set output values for component to parent
- for (const nodePin of nodePins) {
- const componentPin = nullthrows(
- store.componentPins.get(nodePin.componentPinId),
+ return behavior.evaluate(collectInputs(instance, netValues), environment);
+ } catch (error) {
+ // The evaluation functions only see values, so tell which node they belong to
+ throw new Error(
+ `${describeInstance(instance)} could not be evaluated: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ { cause: error },
);
- if (componentPin.type === "output") {
- const implementationNodePin = nullthrows(
- store.nodePins.get(nullthrows(componentPin.implementation)),
- );
- const value = nullthrows(
- innerSimulationFrame.nodes
- .get(implementationNodePin.nodeId)
- ?.pins.get(implementationNodePin.id),
- );
- outerNodePinValues.set(nodePin.id, value);
- }
}
-
- nullthrows(context.currentFrame.nodes.get(nodeId)).child =
- innerSimulationFrame;
- return true;
}
+/**
+ * Simulate one time step of a component. Every flip-flop of the component and of the
+ * components it uses is clocked once, so a flip-flop outputs what its input carried in
+ * the previous time step.
+ * @param store store to read the circuit from
+ * @param componentId component to simulate
+ * @param inputValues values supplied to the input pins of the component
+ * @param previousFrame the previous time step, or null for the first one
+ * @param defaultBitWidth bit width of the pins of the component whose width is not fixed
+ * @returns the values of this time step
+ */
export default function simulateComponent(
store: CCStore,
componentId: CCComponentId,
inputValues: Map,
- previousFrame: SimulationFrame | null,
- defaultBitWidth: number = 1,
+ previousFrame: SimulationFrame | null = null,
+ defaultBitWidth = 1,
): SimulationFrame {
- const currentSimulationFrame = {
- componentId: componentId,
- nodes: new Map<
- CCNodeId,
- {
- pins: Map;
- /** null if intrinsic */
- child: SimulationFrame | null;
- }
- >(),
- };
- const children = store.nodes.getManyByParentComponentId(componentId);
-
- for (const child of children) {
- const pins = store.nodePins.getManyByNodeId(child.id);
- for (const pin of pins) {
- const componentPin = nullthrows(
- store.componentPins.get(pin.componentPinId),
- );
- if (componentPin.type === "input") {
- const connections = nullthrows(
- store.connections.getConnectionsByNodePinId(pin.id),
- );
- if (connections.length === 0) {
- throw new Error(
- `Input pin ${pin.id} of node ${child.id} is not connected.`,
- );
- }
- }
- }
- }
-
- const nodePinInputNumber = new Map();
+ const netlist = getSimulationNetlist(store, componentId, defaultBitWidth);
+ invariant(
+ previousFrame === null || previousFrame.netlist === netlist,
+ "The previous frame was simulated from another revision of the store",
+ );
- // Initialize maps
- for (const child of children) {
- const innerPins = store.nodePins.getManyByNodeId(child.id);
- let inputPinNumber = 0;
- for (const innerPin of innerPins) {
- const componentPin = nullthrows(
- store.componentPins.get(innerPin.componentPinId),
- );
- if (componentPin.type === "input") {
- inputPinNumber += 1;
- }
- }
- nodePinInputNumber.set(child.id, inputPinNumber);
- currentSimulationFrame.nodes.set(child.id, {
- pins: new Map(),
- child: null,
- });
+ const netValues = new Map();
+ for (const [componentPinId, value] of inputValues) {
+ const netId = netlist.netByInputComponentPin.get(componentPinId);
+ if (netId !== undefined) netValues.set(netId, value);
}
- for (const [componentPinId, inputValue] of inputValues) {
- const componentPin = nullthrows(store.componentPins.get(componentPinId));
- if (componentPin.type === "input") {
- const connectedNodePin = nullthrows(
- store.nodePins.get(nullthrows(componentPin.implementation)),
+ for (const instance of netlist.instances) {
+ const outputs = evaluateInstance(instance, netValues, previousFrame);
+ for (const [key, pins] of Object.entries(instance.outputShape)) {
+ const values = nullthrows(
+ outputs[key],
+ `${describeInstance(instance)} did not output ${key}`,
);
- currentSimulationFrame.nodes
- .get(connectedNodePin.nodeId)
- ?.pins.set(connectedNodePin.id, inputValue);
- }
- }
-
- const unevaluatedNodes = new Set();
- for (const child of children) {
- unevaluatedNodes.add(child.id);
- }
-
- const childContext = {
- previousFrame: previousFrame,
- currentFrame: currentSimulationFrame,
- defaultBitWidth,
- };
-
- while (unevaluatedNodes.size > 0) {
- const currentNodeId = nullthrows([...unevaluatedNodes][0]);
- unevaluatedNodes.delete(currentNodeId);
-
- if (simulateNode(store, currentNodeId, childContext)) {
- // propagate output values through connections
- const currentNodePinValues = nullthrows(
- childContext.currentFrame.nodes.get(currentNodeId)?.pins,
+ invariant(
+ values.length === pins.length,
+ `${describeInstance(instance)} output ${values.length} values for the ${pins.length} pins of ${key}`,
);
- for (const [nodePinId, outputValue] of currentNodePinValues) {
- const nodePin = nullthrows(store.nodePins.get(nodePinId));
- const componentPin = nullthrows(
- store.componentPins.get(nodePin.componentPinId),
- );
- if (componentPin.type === "output") {
- const connections = nullthrows(
- store.connections.getConnectionsByNodePinId(nodePinId),
- );
- for (const connection of connections) {
- const toNodePin = nullthrows(store.nodePins.get(connection.to));
- const toNodePinValues = nullthrows(
- childContext.currentFrame.nodes.get(toNodePin.nodeId)?.pins,
- );
- toNodePinValues.set(toNodePin.id, outputValue);
- }
- }
+ for (const [index, pin] of pins.entries()) {
+ netValues.set(pin.netId, nullthrows(values[index]));
}
- } else {
- unevaluatedNodes.add(currentNodeId);
}
}
- return currentSimulationFrame;
+
+ return new SimulationFrame(netlist, netValues);
}
diff --git a/src/store/testUtils.ts b/src/store/testUtils.ts
index cd4903f..7663c0c 100644
--- a/src/store/testUtils.ts
+++ b/src/store/testUtils.ts
@@ -1,6 +1,19 @@
import { uniqBy } from "es-toolkit";
+import nullthrows from "nullthrows";
+import invariant from "tiny-invariant";
import { expect } from "vitest";
-import type CCStore from ".";
+import CCStore from ".";
+import {
+ type CCComponent,
+ type CCComponentId,
+ CCComponentStore,
+} from "./component";
+import type { CCComponentPin } from "./componentPin";
+import { CCConnectionStore } from "./connection";
+import { input, output } from "./intrinsics/definitions";
+import { type CCNode, CCNodeStore } from "./node";
+import { type CCNodePinId, CCNodePinStore } from "./nodePin";
+import type { SimulationValue } from "./simulation";
export function expectConsistentStore(store: CCStore) {
const nodes = store.nodes.getMany();
@@ -29,3 +42,165 @@ export function expectConsistentStore(store: CCStore) {
// Ensure an input pin is only connected to one output pin
expect(connections).toEqual(uniqBy(connections, (c) => c.to));
}
+
+/**
+ * Builders for the circuits used in tests. They go through the same stores the editor
+ * uses, so a circuit built with them behaves like one drawn by hand.
+ */
+
+/** A store with the intrinsic components registered and the stores mounted. */
+export function createTestStore(): CCStore {
+ const store = new CCStore();
+ store.mount();
+ return store;
+}
+
+let nextTestPosition = 0;
+function testPosition() {
+ nextTestPosition += 1;
+ return { x: nextTestPosition * 150, y: 0 };
+}
+
+export function registerTestComponent(
+ store: CCStore,
+ name: string,
+): CCComponent {
+ const component = CCComponentStore.create({ name });
+ store.components.register(component);
+ return component;
+}
+
+/**
+ * Add a node of `componentId` to `parentComponentId`. Mounting the store takes care of
+ * the node pins and, for `Input` and `Output` nodes, of the pin of the parent component.
+ */
+export function registerTestNode(
+ store: CCStore,
+ parentComponentId: CCComponentId,
+ componentId: CCComponentId,
+ config?: CCNode["config"],
+): CCNode {
+ const node = CCNodeStore.create({
+ parentComponentId,
+ componentId,
+ position: testPosition(),
+ });
+ if (config !== undefined) node.config = config;
+ store.nodes.register(node);
+ return node;
+}
+
+/** The pin of `node` instantiating `componentPin` (the `index`-th one if it is split). */
+export function testNodePin(
+ store: CCStore,
+ node: CCNode,
+ componentPin: CCComponentPin,
+ index = 0,
+): CCNodePinId {
+ const nodePins = store.nodePins
+ .getManyByNodeIdAndComponentPinId(node.id, componentPin.id)
+ .toSorted((a, b) => a.order - b.order);
+ return nullthrows(nodePins[index], "node pin not found").id;
+}
+
+/** Split a configurable pin of `node` into pins of the given bit widths. */
+export function splitTestNodePin(
+ store: CCStore,
+ node: CCNode,
+ componentPin: CCComponentPin,
+ widths: number[],
+): void {
+ const existing = store.nodePins.getManyByNodeIdAndComponentPinId(
+ node.id,
+ componentPin.id,
+ );
+ invariant(existing.length === 1 && existing[0]);
+ store.nodePins.update(existing[0].id, {
+ manualBitWidth: nullthrows(widths[0]),
+ });
+ for (let i = 1; i < widths.length; i += 1) {
+ store.nodePins.register(
+ CCNodePinStore.create({
+ nodeId: node.id,
+ componentPinId: componentPin.id,
+ order: i,
+ manualBitWidth: nullthrows(widths[i]),
+ }),
+ );
+ }
+}
+
+export function connectTestNodePins(
+ store: CCStore,
+ parentComponentId: CCComponentId,
+ from: CCNodePinId,
+ to: CCNodePinId,
+): void {
+ const connection = CCConnectionStore.create({
+ from,
+ to,
+ parentComponentId,
+ bentPortion: 0.5,
+ });
+ store.connections.register(connection);
+ invariant(
+ store.connections.get(connection.id),
+ "the store rejected the connection",
+ );
+}
+
+/** Add an `Input` node, which exposes an input pin on the component it is added to. */
+export function registerTestInputNode(
+ store: CCStore,
+ parentComponentId: CCComponentId,
+ name: string,
+) {
+ const node = registerTestNode(store, parentComponentId, input.component.id);
+ const out = testNodePin(store, node, input.outputPin.Out);
+ const componentPin = nullthrows(store.componentPins.getByImplementation(out));
+ store.componentPins.update(componentPin.id, { name });
+ return { node, out, componentPinId: componentPin.id };
+}
+
+/** Add an `Output` node, which exposes an output pin on the component it is added to. */
+export function registerTestOutputNode(
+ store: CCStore,
+ parentComponentId: CCComponentId,
+ name: string,
+) {
+ const node = registerTestNode(store, parentComponentId, output.component.id);
+ const into = testNodePin(store, node, output.inputPin.In);
+ const componentPin = nullthrows(
+ store.componentPins.getByImplementation(into),
+ );
+ store.componentPins.update(componentPin.id, { name });
+ return { node, in: into, componentPinId: componentPin.id };
+}
+
+/** The pin named `name` of a user-defined component. */
+export function testComponentPin(
+ store: CCStore,
+ component: CCComponent,
+ name: string,
+): CCComponentPin {
+ return nullthrows(
+ store.componentPins
+ .getManyByComponentId(component.id)
+ .find((pin) => pin.name === name),
+ `Component ${component.name} has no pin named ${name}`,
+ );
+}
+
+/** MSB-first bit array of `value` in `width` bits. */
+export function testBits(value: number, width: number): SimulationValue {
+ return Array.from(
+ { length: width },
+ (_, i) => ((value >> (width - 1 - i)) & 1) === 1,
+ );
+}
+
+/** Decimal reading of an MSB-first bit array. */
+export function testNumber(value: SimulationValue | undefined): number | null {
+ if (!value) return null;
+ return Number.parseInt(value.map((bit) => (bit ? "1" : "0")).join(""), 2);
+}