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.
This commit is contained in:
Jan-Peter von Hunnius 2026-05-14 21:00:22 +02:00
parent add7ace674
commit 08f53d68fe
28 changed files with 7186 additions and 270 deletions

View file

@ -0,0 +1,603 @@
# 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:
```mermaid
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
```mermaid
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
```mermaid
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
```rust
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:
```rust
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:
```rust
// 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()`:
```rust
// 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
```rust
// 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
```mermaid
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
```rust
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`:
```mermaid
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