rusty_emu/plans/timing_architecture.md
Jan-Peter von Hunnius 08f53d68fe feat: implement virtual clock timing architecture (Phases 5-8)
Introduces a VirtualClock-based timing system replacing wall-clock
Instant-based timing for hardware-accurate simulation.

New modules:
- virtual_clock.rs: Shared virtual time source with condvar sync
- real_time_governor.rs: Thread that advances virtual time
- component_timing.rs: Per-component timing constraints

Key changes:
- Component trait: added update_at_time(), timing(), set_virtual_clock_optional()
- All Intel 400x components migrated to virtual-time-aware operation
- ConfigurableSystem: start() returns Result, spawns governor thread
- GUI: timing controls (speed slider, mode selector, step button)
- Console: speed/mode/step/time commands
- Backward compatible: legacy fallback for components without VirtualClock

Reviewed-by: code review found 2 warnings (clock resume() dead code,
reset() safety docs) — both fixed.
2026-05-14 21:00:22 +02:00

21 KiB
Raw Blame History

Timing-Accurate Simulation Architecture

Problem Statement

The current emulator spawns each component in its own OS thread with independent thread::sleep() calls. This means:

  1. No timing accuracy — nanosleep has ~100µs granularity but components need nanosecond-level timing
  2. No synchronization — Components update independently with no guaranteed ordering
  3. No speed control — No way to slow down, speed up, or single-step
  4. No hardware timing enforcement — Timing constants exist but aren't checked against simulation time

How Other Emulators/Simulators Handle Timing

UAE (Ultimate Amiga Emulator)

UAE uses a line-based timing model:

  • The Amiga's custom chips are synchronized to the video beam position (horizontal line)
  • Each emulated line takes a fixed number of CPU cycles (e.g., 228 cycles for PAL)
  • The CPU runs for one line's worth of cycles, then the custom chips update
  • A host synchronization point occurs once per frame (50/60 Hz) — UAE sleeps to match real-time at the frame boundary, not per-cycle
  • Sound output drives real-time sync: the audio buffer must be filled at the correct rate
  • Key insight: UAE batches many cycles of work, then syncs to wall-clock at coarse granularity (frame rate)

VICE (Versatile Commodore Emulator)

VICE uses a cycle-accurate but batched model:

  • The CPU and CIA/VIC chips each track their own cycle count
  • The main loop runs the CPU for one cycle, then lets the VIC/CIA advance one cycle
  • No per-cycle sleeping — VICE runs cycles as fast as possible
  • Real-time sync happens at the frame boundary (50/60 Hz = every ~20ms)
  • VICE uses the host's audio subsystem or usleep() at frame boundaries to maintain real-time
  • The SID (sound chip) and VIC (video) generate their output in batches, then the host presents it at the right time
  • Key insight: Cycle-accurate internally, wall-clock synced at frame boundaries

Verilog/VHDL Simulators (Icarus Verilog, ModelSim, Verilator)

  • Event-driven scheduling at virtual time points
  • Components register callbacks: "wake me at virtual time T"
  • No wall-clock sleeping at all — pure computation
  • The simulator maintains an event queue sorted by virtual time
  • Key insight: Virtual time is just a number in a priority queue

QEMU

  • Uses instruction counting for timing
  • Each emulated instruction advances a virtual clock counter
  • For real-time workloads (audio, video), QEMU sleeps at coarse boundaries
  • Key insight: Count instructions, sync at coarse intervals

SPICE (Analog Simulator)

  • Adaptive timestep integration
  • Time advances in variable-size steps based on signal activity
  • Smaller steps when signals change rapidly, larger steps when stable
  • Key insight: Variable granularity based on activity

Common Pattern

All these emulators share the same core pattern:

graph LR
    A[Run N cycles of virtual time] --> B[Batch: process all events for those cycles]
    B --> C[Sync: sleep to match wall-clock if needed]
    C --> A

The sync point granularity varies:

Emulator Sync granularity Virtual time per sync
UAE Per video frame ~20ms
VICE Per video frame ~20ms
QEMU Per instruction batch ~1-10ms
Verilog Never (pure computation) N/A
SPICE Never (pure computation) N/A

The key insight for our emulator: We should use virtual time internally (like all of them) and sync to wall-clock at coarse boundaries (like UAE/VICE), not try to nanosleep per cycle.


Proposed Architecture: Virtual-Time-Synchronized Per-Component Threads

Core Idea

Keep each component in its own thread (the project's signature feature), but replace thread::sleep()-based timing with a shared virtual clock that all components synchronize against. Components sleep until the virtual clock reaches their next scheduled update time.

Architecture Diagram

graph TB
    subgraph "Global Synchronization"
        VC[VirtualClock - shared Arc atomic u64 nanoseconds]
        RTG[RealTimeGovernor - advances virtual clock]
        SC[SpeedControl - multiplier + mode]
    end

    subgraph "Component Threads - each runs independently"
        CT1[Clock Thread]
        CT2[CPU Thread]
        CT3[ROM Thread]
        CT4[RAM Thread]
        CT5[ShiftReg Thread]
    end

    subgraph "Per-Component Timing"
        T1[ComponentTiming - access_time, setup_time, etc.]
        T2[TimingChecker - validates constraints against virtual time]
    end

    RTG --> VC
    SC --> RTG
    VC --> CT1
    VC --> CT2
    VC --> CT3
    VC --> CT4
    VC --> CT5
    T1 --> CT2
    T1 --> CT3
    T2 --> CT3

How It Works — Virtual Time Synchronization

sequenceDiagram
    participant Gov as Governor Thread
    participant VC as VirtualClock
    participant CLK as Clock Thread
    participant CPU as CPU Thread
    participant ROM as ROM Thread

    Note over Gov: Governor advances virtual clock in steps
    Gov->>VC: advance to 333ns
    VC->>VC: notify_all - wake sleeping components

    CLK->>VC: read virtual_time = 333ns
    CLK->>CLK: 333ns >= 666ns? No - sleep until 666ns

    CPU->>VC: read virtual_time = 333ns
    CPU->>CPU: Check for clock edges - no change - wait

    Note over Gov: Governor advances
    Gov->>VC: advance to 666ns
    VC->>VC: notify_all

    CLK->>VC: read virtual_time = 666ns
    CLK->>CLK: 666ns >= 666ns? Yes! Toggle PHI1 LOW, PHI2 HIGH
    CLK->>CLK: Schedule next transition at 1333ns
    CLK->>VC: wait_until 1333ns

    CPU->>VC: read virtual_time = 666ns
    CPU->>CPU: Detect PHI2 rising - process data phase

    ROM->>VC: read virtual_time = 666ns
    ROM->>ROM: See address on bus. Record latch_time = 666ns
    ROM->>ROM: Need 500ns access time. Data ready at 1166ns
    ROM->>VC: wait_until 1166ns

    Note over Gov: Governor advances
    Gov->>VC: advance to 1166ns

    ROM->>VC: read virtual_time = 1166ns
    ROM->>ROM: 1166 - 666 = 500ns >= 500ns access time. Drive data!

Key Components

1. VirtualClock — Shared Virtual Time Source

pub struct VirtualClock {
    /// Current virtual time in nanoseconds
    time_ns: AtomicU64,
    /// Target time for next step (components wait for this)
    target_ns: AtomicU64,
    /// Condvar for notifying components when time advances
    time_advanced: Condvar,
    /// Mutex for condvar
    state_mutex: Mutex<()>,
    /// Speed multiplier: 1.0 = real-time, 0.1 = 10x slower
    speed_multiplier: AtomicU64,  // Stored as fixed-point
    /// Simulation mode
    mode: AtomicU8,  // 0=RealTime, 1=MaxSpeed, 2=SingleStep
    /// Single-step trigger
    step_pending: AtomicBool,
}

impl VirtualClock {
    /// Get current virtual time
    pub fn now(&self) -> Duration {
        Duration::from_nanos(self.time_ns.load(Ordering::SeqCst))
    }

    /// Block until virtual time reaches at least target
    /// Returns the actual virtual time when woken
    pub fn wait_until(&self, target: Duration) -> Duration {
        let target_ns = target.as_nanos() as u64;
        self.target_ns.store(target_ns, Ordering::SeqCst);

        loop {
            let current = self.time_ns.load(Ordering::SeqCst);
            if current >= target_ns {
                return Duration::from_nanos(current);
            }
            // Wait for governor to advance time
            let guard = self.state_mutex.lock();
            self.time_advanced.wait(guard);
        }
    }

    /// Non-blocking check: has virtual time reached target?
    pub fn has_reached(&self, target: Duration) -> bool {
        self.time_ns.load(Ordering::SeqCst) >= target.as_nanos() as u64
    }
}

2. RealTimeGovernor — Advances Virtual Clock

Runs in its own thread. Advances the shared virtual clock based on speed multiplier:

pub struct RealTimeGovernor {
    clock: Arc<VirtualClock>,
    tick_size_ns: u64,     // Virtual nanoseconds per tick (e.g., 333)
    speed_multiplier: f64, // 1.0 = real-time
}

impl RealTimeGovernor {
    fn run(&mut self) {
        let mut last_wall_time = Instant::now();

        loop {
            match self.clock.mode() {
                SimulationMode::RealTime => {
                    // Calculate how long to sleep to match speed_multiplier
                    let virtual_advance = Duration::from_nanos(self.tick_size_ns);
                    let target_wall = virtual_advance.div_f64(self.speed_multiplier);

                    let elapsed = last_wall_time.elapsed();
                    if elapsed < target_wall {
                        thread::sleep(target_wall - elapsed);
                    }

                    // Advance virtual time
                    self.clock.advance_by(self.tick_size_ns);
                    last_wall_time = Instant::now();
                }

                SimulationMode::MaxSpeed => {
                    // Advance as fast as possible
                    self.clock.advance_by(self.tick_size_ns);
                    // Yield occasionally to prevent starving other threads
                    if self.clock.now().as_nanos() % 1000 == 0 {
                        thread::yield_now();
                    }
                }

                SimulationMode::SingleStep => {
                    // Wait for user to trigger a step
                    if self.clock.consume_step_trigger() {
                        self.clock.advance_by(self.tick_size_ns);
                    } else {
                        thread::sleep(Duration::from_millis(1));
                    }
                }
            }
        }
    }
}

3. Component Thread Pattern

Each component thread follows this pattern:

// In ConfigurableSystem::start() — for each component:
let clock_clone = Arc::clone(&virtual_clock);
let comp_clone = Arc::clone(&component);

thread::spawn(move || {
    let mut next_update_time = Duration::ZERO;
    let timing = ComponentTiming::for_component(&*comp_clone.lock());

    loop {
        // Wait for virtual time to reach our next scheduled update
        let current_vtime = clock_clone.wait_until(next_update_time);

        if !clock_clone.is_running() {
            break;
        }

        // Lock component and update
        if let Some(mut comp) = comp_clone.try_lock() {
            comp.update_at_time(current_vtime);
        }

        // Schedule next update based on component's timing needs
        next_update_time = current_vtime + timing.minimum_update_interval();
    }
});

4. Per-Component Timing with Virtual Time

Components use virtual time instead of Instant::now():

// In Intel4001 (ROM):
impl Component for Intel4001 {
    fn update_at_time(&mut self, virtual_time: Duration) {
        let (phi1, phi2) = self.read_clock_pins();

        let phi1_rising = phi1 == PinValue::High && self.prev_phi1 == PinValue::Low;

        if phi1_rising {
            // Record when address was latched (in virtual time!)
            self.address_latch_time = Some(virtual_time);
            self.handle_phi1_rising();
        }

        if phi2_rising {
            // Check if access time has elapsed IN VIRTUAL TIME
            if let Some(latch_time) = self.address_latch_time {
                let elapsed_virtual = virtual_time.saturating_sub(latch_time);
                if elapsed_virtual >= self.timing.access_time {
                    // Data is valid — drive it
                    self.handle_phi2_rising();
                } else {
                    // Access time NOT yet elapsed — data not ready!
                    // This happens when the clock is overclocked
                    self.handle_data_not_ready();
                }
            }
        }

        self.prev_phi1 = phi1;
        self.prev_phi2 = phi2;
    }
}

5. Clock Component with Virtual Time

// In TwoPhaseClock:
impl Component for TwoPhaseClock {
    fn update_at_time(&mut self, virtual_time: Duration) {
        // Check if it's time to transition (using virtual time)
        let elapsed = virtual_time.saturating_sub(self.last_transition_vtime);

        match self.current_phase {
            Phase::Phi1High => {
                if elapsed >= self.phase_time {
                    self.phi1_state = PinValue::Low;
                    self.phi2_state = PinValue::High;
                    self.last_transition_vtime = virtual_time;
                    self.update_outputs();
                }
            }
            Phase::Phi2High => {
                if elapsed >= self.phase_time {
                    self.phi2_state = PinValue::Low;
                    self.phi1_state = PinValue::High;
                    self.last_transition_vtime = virtual_time;
                    self.update_outputs();
                }
            }
        }
    }
}

Thread Architecture

graph LR
    subgraph "Governor Thread"
        G[RealTimeGovernor]
        G -->|advance_by tick_size| VC[VirtualClock]
    end

    subgraph "Component Thread 1"
        C1[TwoPhaseClock]
        C1 -->|wait_until next_transition| VC
    end

    subgraph "Component Thread 2"
        C2[Intel4004]
        C2 -->|wait_until next_cycle| VC
    end

    subgraph "Component Thread 3"
        C3[Intel4001]
        C3 -->|wait_until access_time_elapsed| VC
    end

    subgraph "Component Thread N"
        CN[...]
        CN -->|wait_until scheduled_time| VC
    end

    VC -->|notify_all on advance| C1
    VC -->|notify_all on advance| C2
    VC -->|notify_all on advance| C3
    VC -->|notify_all on advance| CN

Each component thread:

  1. Calls virtual_clock.wait_until(target_time) — blocks until the governor advances virtual time past the target
  2. Wakes up, reads pins, processes its logic
  3. Schedules its next wake-up time based on its timing constraints
  4. Goes back to sleep

The governor thread:

  1. Advances virtual time by tick_size (e.g., 333ns)
  2. Notifies all waiting component threads
  3. Sleeps to match wall-clock time × speed_multiplier (if in RealTime mode)

Component Timing Model

pub struct ComponentTiming {
    pub access_time: Duration,          // Time from address valid to data ready
    pub propagation_delay: Duration,    // Output delay after input change
    pub setup_time: Duration,           // Input stability before clock edge
    pub hold_time: Duration,            // Input stability after clock edge
    pub max_clock_frequency: f64,       // Maximum supported clock frequency
    pub min_update_interval: Duration,  // How often update_at_time should be called
}

// Per-component defaults from datasheets:
impl Intel4001 { // ROM - 500ns access
    fn timing() -> ComponentTiming {
        ComponentTiming {
            access_time: Duration::from_nanos(500),
            propagation_delay: Duration::from_nanos(200),
            setup_time: Duration::from_nanos(100),
            hold_time: Duration::from_nanos(50),
            max_clock_frequency: 750_000.0,
            min_update_interval: Duration::from_nanos(100), // Check every 100ns
        }
    }
}

impl Intel4002 { // RAM - 500ns access
    fn timing() -> ComponentTiming {
        ComponentTiming {
            access_time: Duration::from_nanos(500),
            propagation_delay: Duration::from_nanos(200),
            setup_time: Duration::from_nanos(100),
            hold_time: Duration::from_nanos(50),
            max_clock_frequency: 750_000.0,
            min_update_interval: Duration::from_nanos(100),
        }
    }
}

impl Intel4003 { // Shift Register - faster 200ns
    fn timing() -> ComponentTiming {
        ComponentTiming {
            access_time: Duration::from_nanos(200),
            propagation_delay: Duration::from_nanos(100),
            setup_time: Duration::from_nanos(50),
            hold_time: Duration::from_nanos(30),
            max_clock_frequency: 1_000_000.0,
            min_update_interval: Duration::from_nanos(50),
        }
    }
}

Speed Control

Mode Multiplier Governor Behavior Use Case
RealTime 0.001× Sleep 333µs per 333ns tick Observe individual clock edges
RealTime 0.01× Sleep 33.3µs per 333ns tick Slow-motion debugging
RealTime 0.1× Sleep 3.33µs per 333ns tick Slower than real-time
RealTime 1.0× Sleep 333ns per tick (≈ max speed) Attempt real-time
MaxSpeed ∞ No sleep, yield occasionally Fastest possible simulation
SingleStep — Advance one tick per trigger Debug one phase at a time

At 1.0× and above, the OS cannot keep up (333ns is below scheduling granularity). The governor will run as fast as it can and report the actual achieved speed (e.g., "Target: 1.0×, Achieved: 0.47×").


Overclocking Behavior

When the clock frequency exceeds a component's max_clock_frequency:

graph TD
    OC[Clock set to 2 MHz - 500ns cycle]
    ROM1[ROM access_time = 500ns]
    RAM1[RAM access_time = 500ns]
    SR1[ShiftReg access_time = 200ns]

    OC -->|Phase = 250ns| ROM1
    OC -->|Phase = 250ns| RAM1
    OC -->|Phase = 250ns| SR1

    ROM1 -->|250ns < 500ns| FAIL1[ROM data NOT ready - CPU reads garbage]
    RAM1 -->|250ns < 500ns| FAIL2[RAM data NOT ready - CPU reads garbage]
    SR1 -->|250ns >= 200ns| OK[ShiftReg responds correctly - its faster]

This is exactly what happens in real hardware — the shift register (200ns access) keeps working while ROM/RAM (500ns access) fails.


Migration Strategy

Phase 1: Create VirtualClock (new file, non-breaking)

  • New file: src/virtual_clock.rs
  • Contains VirtualClock struct with atomic virtual time, condvar, and speed control
  • No changes to existing code

Phase 2: Create RealTimeGovernor (new file, non-breaking)

  • New file: src/real_time_governor.rs
  • Governor thread that advances VirtualClock based on speed multiplier
  • No changes to existing code

Phase 3: Create ComponentTiming (new file, non-breaking)

  • New file: src/component_timing.rs
  • ComponentTiming struct and per-component defaults
  • No changes to existing code

Phase 4: Extend Component trait with virtual time support

  • Add update_at_time(&mut self, virtual_time: Duration) with default that calls update()
  • Add timing(&self) -> ComponentTiming with default that returns zero-delay timing
  • Backward-compatible — existing update() still works

Phase 5: Migrate clock components to virtual time

  • GenericClock and TwoPhaseClock: Replace Instant::now() with virtual time parameter
  • Clock transitions based on virtual time comparisons
  • Clocks call virtual_clock.wait_until(next_transition)

Phase 6: Add timing enforcement to memory/CPU components

  • Intel4001, Intel4002, Intel4003: Check access times against virtual time
  • Intel4004: Check setup/hold times
  • Record timing violations for reporting

Phase 7: Update ConfigurableSystem to use VirtualClock

  • Create Arc<VirtualClock> shared by all components
  • Governor thread manages virtual time advancement
  • Component threads use virtual_clock.wait_until() instead of thread::sleep()
  • Keep the per-component thread model — just change how they wait

Phase 8: Add speed/step controls to GUI and console

  • Speed slider (logarithmic: 0.001× to MaxSpeed)
  • Step buttons: Step Phase, Step Cycle, Step Instruction
  • Display: actual speed, virtual time, timing violations

Phase 9: Update tests

  • Tests use MaxSpeed mode for fast deterministic execution
  • Tests use SingleStep mode for precise state verification
  • Add overclocking tests: verify ROM fails at 2 MHz but shift register still works

Key Differences from Single-Scheduler Approach

Aspect Single Scheduler Per-Component Threads + VirtualClock
Threading model 1 scheduler thread N component threads + 1 governor thread
Component autonomy Components are passive — called by scheduler Components are active — they wait for their own timing
Realism Less realistic — all components update in strict order More realistic — components race independently, just like real hardware
Timing enforcement Centralized in scheduler Distributed — each component enforces its own timing
Race conditions None — deterministic order Possible — but that's realistic! Real hardware has races too
Speed control Scheduler controls tick rate Governor controls virtual clock rate
Complexity Simpler More complex but more faithful to hardware

Benefits

  1. Per-component threads preserved — The project's signature feature remains
  2. Hardware-accurate timing — Each component enforces its real timing constraints
  3. Overclocking realism — Components fail when clocked too fast, just like real hardware
  4. Virtual time — No dependency on OS scheduling granularity
  5. Speed control — Logarithmic multiplier from 0.001× to MaxSpeed
  6. Single-step — Step by phase, cycle, or instruction
  7. Realistic races — Components compete for the bus independently, modeling real hardware behavior
  8. Backward compatible — Phases 1-3 are additive; existing code continues to work during migration