Version: 1.0.0 Project Type: Unity game engine - logic circuit simulator Language: C# Core Frameworks: Unity, Newtonsoft.Json
Digital Logic Sim (DLS) is an interactive educational tool for designing and simulating digital logic circuits. The architecture cleanly separates the editing interface (game layer) from the simulation engine, allowing independent development and performance optimization. The system supports both built-in logic primitives and user-created custom chips.
The current development priorities for this project are:
- Performance Improvements - Optimize simulation speed and responsiveness, especially for large circuits
- Predictable Behaviors - Reduce surprising or unintuitive behavior in the simulator; make the system more deterministic and understandable
- Best Practices - Incorporate better architectural patterns, code quality, and maintainability improvements
- Future Migration - Eventually migrate from Unity to a more FOSS-friendly engine (considering Godot)
When working on this codebase, prioritize changes that align with these objectives. Performance optimizations and behavior clarifications are highly valued. Keep portability in mind when making architectural decisions.
The simulation layer runs on a separate thread from the main game and performs all logic processing independently of UI/rendering.
Key Components:
-
Simulator.cs - Static orchestrator managing the entire simulation
- Maintains dirty chip queue (chips needing processing this frame)
- Builds SimChip tree from ChipDescription during initialization
- Applies thread-safe modifications from main thread queue
- Tracks simulation frame counter for state synchronization
- Two-pass algorithm: ordering pass (determines processing sequence), then incremental updates
-
SimChip.cs - Runtime representation of a chip instance
- Holds array of InputPins, OutputPins, SubChips, and internal state
- Immutable after creation (ChipType, ID, SubChips array)
- Dynamically resizable pin arrays for editing
- Processes itself via ChipProcessorFactory polymorphism
- Recursive structure: custom chips contain subchips recursively
-
SimPin.cs - Individual pin state and signal propagation
- State is uint32 (bitfield + tri-state flags, 16 bits each)
- Tracks input connections (handles conflicting signals randomly)
- PropagateSignal() copies state to all ConnectedTargetPins
- Marks parent chip dirty when state changes
- Records last update frame to detect multi-input conflicts
-
ChipProcessors/ - Pluggable execution strategy pattern
- BaseChipProcessor abstract base with StepChip() template
- Each ChipType has dedicated processor (NandChipProcessor, ClockChipProcessor, etc.)
- CustomChipProcessor just handles dynamic reordering; subchip work via dirty queue
- ChipProcessorFactory singleton manages processor lookup dictionary
-
PinState.cs - Tri-state logic encoding (0=low, 1=high, 2=disconnected)
- Packed into uint as: lower 16 bits = signals, upper 16 bits = tri-state flags
- Handles multi-bit pin conversions (Split/Merge chips)
Simulation Loop (RunSimulationStep):
- Copy player input states to root chip input pins
- If first frame or chip modified: StepChipReorder() - determine optimal processing order
- Otherwise: ProcessOneDirtyChip() from queue
- When chip processes: inputs propagate → processor runs → outputs propagate
- Pin state changes automatically add parent chip to dirty queue
- Continue until no chips remain dirty
Thread Safety:
- Modification queue (ConcurrentQueue) accepts main thread changes
- ApplyModifications() called only on simulation thread before processing
- Dirty chip queue prevents data races on pin state reads
Pure data structures representing chip definitions (no runtime behavior). Used for persistence and building simulation structures.
Core Types:
-
ChipDescription - Defines a chip template
- Name (case-insensitive with NameComparer)
- ChipType enum (Nand, Clock, Custom, etc.)
- Arrays of InputPins[], OutputPins[], SubChips[], Wires[]
- Color, Size, Displays[] metadata
-
SubChipDescription - References a subchip instance within parent
- Name, ID (unique within parent), Label, Position
- InternalData[] for ROM/RAM/KEY chip-specific data
- OutputPinColourInfo[] for visual customization
-
WireDescription - Connection between two pins
- SourcePinAddress, TargetPinAddress (PinAddress = {PinOwnerID, PinID})
- Visual routing points and connection type metadata
-
PinDescription - Individual pin template
- ID (unique within chip), Name, BitCount enum (1/4/8-bit)
Related:
- ProjectDescription - Project metadata, starred chips, collections
- AppSettings - User preferences (resolution, vsync, sim speed)
Key Pattern: All Description types are POCO (plain old C# objects) for JSON serialization. No methods except convenience comparisons.
Everything for interactive editing and visualization. Runs on main thread.
Project & Editing:
-
Project.cs - Top-level coordinator
- Holds ProjectDescription, ChipLibrary
- Manages chip view stack (nested editing, enter/return)
- Spawns simulation thread in background
- Buffers edits for thread-safe handoff to simulator
-
DevChipInstance.cs - Currently-being-edited chip
- Lists of Elements (SubChipInstance, PinInstance, WireInstance, DisplayInstance)
- Builds SimChip via DescriptionCreator → Simulator.BuildSimChip()
- Tracks LastSavedDescription for undo/unsaved change detection
- UndoController manages edit history
-
ChipLibrary.cs - Dictionary of all available chips
- Separates builtin vs custom chips internally
- Both accessible via unified GetChipDescription(name) lookup
- Automatically hides BusTerminus and dev_Ram_8Bit from menus
- NotifyChipSaved() updates on save; RemoveChip() on delete
-
SubChipInstance.cs - Reference to a placed chip instance
- Immutable Description and ChipType
- Mutable: Position, Label, InternalData
- Calculates min size from pin counts
- Tracks all pins via AllPins array (input+output)
-
PinInstance.cs, WireInstance.cs, DisplayInstance.cs - Individual elements
-
DevPinInstance.cs - Input/output ports on the currently-edited chip
Built-in Chips:
-
BuiltinChipCreator.cs - Factory generating ChipDescription for all built-ins
- Nand (1-bit logic gate)
- Clock, Pulse, Key (input sources)
- TriStateBuffer, Bus/BusTerminus (fan-out/multi-source)
- Split/Merge (bit width conversion)
- Displays (7-seg, RGB, Dot, LED)
- ROM_256x16, dev_Ram_8Bit (memory)
- Buzzer (audio output)
-
BuiltinCollectionCreator.cs - UI menu organization
Interaction & UI:
- ChipInteractionController.cs - Handle mouse/keyboard for editing
- KeyboardShortcuts.cs - Global hotkeys
- UndoController.cs - Edit history with undo/redo
Graphics:
- Graphics/ folder - Rendering (uses custom SebVis drawing library, not detailed here)
- SaveSystem/ - JSON serialization + file I/O
Serializer.cs - JSON marshalling wrapper
- Custom Vector2 and Color converters
- Uses Newtonsoft.Json for round-tripping
Loader.cs - Building runtime structures from disk
- LoadProjectDescription() reads JSON → ProjectDescription
- LoadChipLibrary() combines custom chips + builtins
- Handles version compatibility via UpgradeHelper
Saver.cs - Persisting to disk
- SaveProjectDescription() timestamps and writes
- SaveChip() individual chip JSON
- CloneChipDescription() deep copy via round-trip serialization
SavePaths.cs - Directory layout (~/.local/share/DLS/)
- AllData/
- Projects/
- {ProjectName}/
- description.json
- Chips/
- {ChipName}.json
- DeletedChips/
- {ProjectName}/
- AppSettings.json
- Projects/
- Description (ChipDescription, SubChipDescription): Immutable template, serialized to JSON
- Game Instance (DevChipInstance, SubChipInstance): Editable working copy on main thread
- Simulation Instance (SimChip): Optimized runtime structure for simulation thread
Flow: Description → DevChipInstance (edit) → DescriptionCreator → ChipDescription (save) → Simulator.BuildSimChip() → SimChip
Built-ins:
- Generated at runtime via BuiltinChipCreator
- Cannot be deleted, always available
- Processors hardcoded in ChipProcessorFactory
Custom:
- Loaded from project's Chips/ folder
- Fully user-defined; built from subchips + wires
- CustomChipProcessor delegates to subchip dirty queue
- Can be edited, renamed, deleted (with backup)
Namespace Collision: If custom chip shares name with new built-in (after update), custom takes precedence. Old built-in hidden from library.
Pin state encodes three-state logic (low/high/disconnected) to handle multiple sources (buses, switches, open-drain logic).
uint pinState:
[upper 16 bits] tri-state flags (1 = disconnected, 0 = driven)
[lower 16 bits] bit values (1 = high, 0 = low)
Signal reception on multi-input pins:
- First input this frame: accepted
- Subsequent input: random choice between AND/OR of conflicting bits
- Tri-stated bits always accept input (high-impedance)
- Randomness randomized per frame for race condition variety
Key insight: Only process chips whose inputs changed. Pin state changes trigger parent chip dirty marking.
Normal frames:
- Start with root chip in queue (or continue from prior frame)
- Dequeue chip, run processor
- Output propagation may mark other chips dirty
- Repeat until queue empty
Ordering frames (first frame or after modification):
- StepChipReorder() does full traversal with random fallback
- Establishes processing sequence for subsequent frames
- Allows deterministic ordering for combinational logic
Race condition handling:
- Every 100 frames, CustomChipProcessor randomly reorders adjacent ready chips
- Gives different resolution outcomes for simultaneous updates (SR latches)
Assets/Scripts/
├── Simulation/ # Runs on separate thread
│ ├── Simulator.cs # Main orchestrator
│ ├── SimChip.cs # Runtime chip instance
│ ├── SimPin.cs # Pin state + propagation
│ ├── ChipProcessors/ # Strategy pattern processors
│ ├── PinState.cs # Tri-state encoding
│ └── SimAudio.cs # Audio state
├── Description/ # POCO data for serialization
│ ├── Types/
│ │ ├── ChipDescription.cs
│ │ ├── ProjectDescription.cs
│ │ ├── SubTypes/
│ │ │ ├── ChipTypes.cs # Chip type enum
│ │ │ ├── PinDescription.cs
│ │ │ └── WireDescription.cs
│ └── Serialization/
│ ├── Serializer.cs # JSON marshalling
│ └── UnsavedChangeDetector.cs
├── Game/ # Main thread - editing & UI
│ ├── Main/
│ │ └── Main.cs # Entry point, project loading
│ ├── Project/
│ │ ├── Project.cs # Top coordinator
│ │ ├── DevChipInstance.cs # Currently-edited chip
│ │ ├── ChipLibrary.cs # Chip registry
│ │ └── BuiltinChipCreator.cs
│ ├── Elements/
│ │ ├── SubChipInstance.cs # Placed chip
│ │ ├── PinInstance.cs
│ │ ├── WireInstance.cs
│ │ └── DisplayInstance.cs
│ └── Interaction/
│ ├── ChipInteractionController.cs
│ └── UndoController.cs
├── SaveSystem/ # Disk I/O
│ ├── Saver.cs
│ ├── Loader.cs
│ ├── SavePaths.cs
│ └── Serializer.cs
└── Graphics/ # Rendering (SebVis library)
- Add ChipType enum in
ChipTypes.cs - Create processor in
ChipProcessors/inheriting BaseChipProcessor- Override ProcessChip() with logic
- Override ChipType property
- Create descriptor factory in BuiltinChipCreator
- Call CreateBuiltinChipDescription() helper
- Register processor in ChipProcessorFactory.Initialize()
- Add to output in BuiltinChipCreator.CreateAllBuiltinChipDescriptions()
- Edit processor's ProcessChip() method
- Read input pin states:
chip.InputPins[i].State - Modify output pin states:
chip.OutputPins[i].State = newValue - Pin state setter automatically marks chip dirty when changed
- For internal state (ROM/RAM): access
chip.InternalState[]directly
- Main thread: Queue modification via Simulator.AddPin() / RemovePin()
- Sim thread: ApplyModifications() calls SimChip.AddPin() / RemovePin()
- Resizes pin arrays; triggers needsOrderPass
- User edits DevChipInstance
- DescriptionCreator.CreateChipDescription() converts to description
- Saver.SaveChip() JSON encodes and writes to Chips/ folder
- Loader.LoadChipLibrary() reads back, registers in ChipLibrary
- Next time opened: Simulator.BuildSimChip() builds from description
Dirty Queue Optimization: Only processes chips whose inputs changed this frame. Significant speedup for large circuits.
Processor Factory: Dictionary lookup instead of switch statement for better scalability.
Array Over Collections: Pin arrays sized exactly (Array.Resize); no LinkedList overhead.
Thread Isolation: Simulation thread independent from main thread minimizes locks.
Caution: ModificationQueue is ConcurrentQueue but still vulnerable if simulation reads while modifying. Comment notes this risk for future improvement.
Projects store DLSVersion_LastSaved and DLSVersion_EarliestCompatible. UpgradeHelper applies schema migrations when loading old projects. Allows forward/backward compatibility within defined range.
- SanityTests.cs - Newtonsoft serialization unit tests
- debug_logSimTime - Toggles sim performance logging
- debug_runSimMainThread - Forces simulation on main thread for debugging
- Separation of Concerns: Description ↔ Game ↔ Simulation are distinct
- Simulation Independence: Can be tested/optimized separately; threading hidden from game logic
- Polymorphism via Factory: Adding chip types requires no switch statement modifications
- Lazy Computation: Dirty queue avoids reprocessing unchanged state
- Immutable Descriptions: JSON round-trip for cloning; no manual deep copy logic
- Tri-State Encoding: Compact bit-packing allows efficient multi-source handling
- Pin lookup is O(n): GetSimPinFromAddress() iterates arrays; could be dict for large chips
- Race conditions with threads: Comments warn of access from main/sim thread simultaneously; mitigated by modification queue
- Bus terminus hidden: Automatically created; user can't place directly
- Custom chips can shadow built-ins: Name collision resolved in built-in's favor; might confuse users
- Wires store indices: Can fail to load if pins deleted from referenced chips (handled gracefully)
- ROM/RAM state: Serialized in SubChipDescription.InternalData; must update if chip modified
- Simulator.cs - Understand the orchestration and dirty queue
- SimChip.cs - See recursive structure and processor pattern
- SimPin.cs - Understand pin state and signal propagation
- ChipDescription.cs - See the data model
- ChipLibrary.cs - Built-in vs custom chip handling
- BuiltinChipCreator.cs - Pattern for defining new chips
- Project.cs - How game layer coordinates everything
- Individual processor implementations as needed
Modified the simulation loop to process signals in level-by-level waves (like Dijkstra's algorithm edge propagation):
// Old: Process only ONE chip per frame
ProcessOneDirtyChip();
// New: Process entire current wave per frame
int chipsInCurrentWave = dirtyChips.Count;
for (int i = 0; i < chipsInCurrentWave; i++)
{
ProcessOneDirtyChip();
}Goal: Allow single-stepping through logic propagation one "hop" at a time for debugging and visualization.
Symptom: Signal propagates to chip input (wire toggles) but chip doesn't process.
Test case:
- 2 NANDs wired in feedback loop (alternates correctly ✓)
- First NAND output → OR gate input (wire toggles ✓)
- OR gate output never goes live (✗)
Hypothesis: OR isn't being added to dirty queue when its input changes.
Key mechanism to verify:
SimPin.Statesetter (SimPin.cs:21) should callSimulator.AddDirtyChip(parentChip)AddDirtyChip()(Simulator.cs:45) usesContains()check - might prevent re-queueing?
Debugging approach:
- Use Unity debugger attached to inspect dirtyChips queue state
- Check if OR is in queue but not being processed
- Verify pin state changes are triggering dirty marking
See godot-migration branch for:
docs/godot-migration-plan.md- 6-phase migration timelinedocs/godot-architecture.md- Technical architecture mapping
Migration will use GDScript (not C#) to avoid Microsoft/CLR dependency while maintaining FOSS principles.