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

603 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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