Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ ringbuf = "0.4"
hound = "3.5"
rfd = "0.17.2"
gilrs = "0.11"
# Already pulled in transitively; used directly only for localtime_r, so
# auto-generated filenames stamp in the host's local time zone.
# Already pulled in transitively; used directly only for localtime_r (local
# time-zone filename stamps) and, on macOS, the pthread QoS class used for the
# optional realtime-priority feature (see src/priority.rs).
libc = "0.2"

# macOS dock icon: winit's with_window_icon is a no-op on macOS, so the dock
Expand All @@ -48,6 +49,13 @@ objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSData"] }

# Optional realtime-priority feature: cross-platform thread scheduling control.
# Only used off macOS -- the macOS path uses the libc pthread QoS API directly
# (Core Audio already runs the audio callback real-time), so this crate (and
# the windows-sys it pulls in) never enters the macOS build.
[target.'cfg(not(target_os = "macos"))'.dependencies]
thread-priority = "3.1"

[lints.clippy]
# Chipset pipeline functions take per-signal argument lists that mirror the
# hardware; bundling them into structs would obscure the data flow.
Expand Down
11 changes: 11 additions & 0 deletions copperline.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ power_on = true
# one run.
pacing_budget = "cycles"

# Ask the OS to schedule Copperline's latency-critical threads (the
# wall-clock pacer and the audio callback) above normal, to reduce frame
# stutter and audio glitches when the host is under load. Best effort and off
# by default: on macOS the pacer thread joins the USER_INTERACTIVE QoS class
# (the audio callback is already real-time under Core Audio); on Windows it is
# raised via SetThreadPriority; on Linux raising priority needs privilege (an
# rtprio rlimit, CAP_SYS_NICE, or root) and is otherwise declined without
# failing the run. The COPPERLINE_REALTIME_PRIORITY env var overrides this for
# one run (set it to 0/false/off to force it off).
# realtime_priority = false


[cpu]

Expand Down
17 changes: 17 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ clock works.
[emulation]
power_on = true # false = start powered off at the test screen
pacing_budget = "cycles" # "cycles" (hardware-accurate) or "instructions"
realtime_priority = false # true = raise the pacer/audio thread priority
```

The deterministic cycle-driven core is the only emulation timing. It is
Expand All @@ -126,6 +127,22 @@ carried no information.)
which is cheaper but runs the CPU faster than hardware.
`COPPERLINE_REAL_PACING_BUDGET` overrides this for one run. See
[](../internals/timing) for the full rationale.
- `realtime_priority = true` asks the OS to schedule Copperline's two
latency-critical threads -- the wall-clock pacer and the audio callback --
above normal, which reduces frame stutter and audio glitches when the host
is busy. It is best effort and off by default, and never fails the run:
- **macOS** -- the pacer thread joins the `USER_INTERACTIVE` QoS class. The
audio callback is left alone because Core Audio already runs it on a
real-time thread (overriding that would only demote it).
- **Windows** -- both threads are raised via `SetThreadPriority`; no
privilege required.
- **Linux/other Unix** -- raising priority needs privilege (an `rtprio`
rlimit, `CAP_SYS_NICE`, or root). Without it the request is logged and
declined, and the thread keeps normal scheduling.

`COPPERLINE_REALTIME_PRIORITY` overrides this for one run; set it to
`0`/`false`/`off` to force it off, or to any other value (or leave it empty)
to force it on.

## `[cpu]`

Expand Down
11 changes: 11 additions & 0 deletions docs/internals/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ src/
rtc.rs # MSM6242-compatible battery RTC
serial.rs # Paula serial sink (stdout)
audio.rs # AudioSink trait + cpal/WAV/null outputs
priority.rs # opt-in realtime-like thread scheduling (pacer + audio)
gamepad.rs # gilrs input + guided calibration
screenshot.rs # PNG export helpers
recorder.rs # video+audio capture (ZMBV/PCM AVI writer)
Expand Down Expand Up @@ -97,6 +98,16 @@ The flow of a frame:
wall-clock; for headless captures it does not. The emulated result is
identical either way -- pacing only schedules host work.

So an interactive run uses three host threads: the **main thread** (event
loop, core, and pacer), the **`copperline-render` worker**, and the
**cpal audio callback** that cpal owns. Only the last two cross a thread
boundary with the main thread, and both do so through owned data (a
`RenderInput`/presentation buffer over a channel, and a lock-free sample ring
buffer) rather than shared mutable state. The pacer and the audio callback are
latency-critical and can optionally be given above-normal scheduling priority
(`[emulation] realtime_priority`, `src/priority.rs`); see
[](timing) for what that does per platform.

## The Bus

`Bus` (`src/bus.rs`) owns all shared machine state: chip/slow/fast RAM and
Expand Down
33 changes: 33 additions & 0 deletions docs/internals/timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,39 @@ accurate per-instruction costs the CPU's chip-bus slot ratio settles at a
physically valid value naturally, which (together with the area-fill C-slot
fix) resolved that blanking regression.

### Thread scheduling priority

Pacing only works if the host actually runs the right thread at the right
moment. Two threads are latency-critical: the **pacer** (the main thread,
which advances the core and calls `thread::sleep` in
`Emulator::sleep_until_realtime_device_time`) and the **cpal audio callback**
(which drains the sample ring buffer the pacer keeps ~150 ms ahead of the
device clock). The `copperline-render` worker ([](architecture)) is a
throughput thread, not a latency one, and is left at normal priority. When the
host is busy, a scheduler that preempts the pacer shows up as frame stutter,
and one that preempts the audio callback shows up as an audible underrun.

`[emulation] realtime_priority` (off by default; `COPPERLINE_REALTIME_PRIORITY`
overrides it for one run) asks the OS to schedule those two threads above
normal. It is best effort -- `src/priority.rs` logs what it did and never fails
the run -- and, like all pacing, it never changes emulated behaviour; it only
changes when host work is scheduled. The implementation is per-platform because
"real-time priority" is portable in neither API nor semantics:

- **macOS** -- the pacer thread joins the `USER_INTERACTIVE` QoS class
(`pthread_set_qos_class_self_np`), the idiomatic unprivileged low-latency
request. The audio callback is left untouched: Core Audio already runs it on
a real-time thread, and pinning a QoS class onto it would only *demote* it.
- **Windows** -- both threads are raised to `THREAD_PRIORITY_HIGHEST` via the
`thread-priority` crate; no privilege required.
- **Linux / other Unix** -- raising priority needs privilege (an `rtprio`
rlimit, `CAP_SYS_NICE`, or root); without it the request is logged and
declined, and the thread keeps normal scheduling.

The pacer sleeps between work chunks rather than spinning, so even the
strongest scheduling class it can land in still yields the CPU and cannot
starve the host -- which is why elevating it is safe to offer.

## Cross-checking against hardware

`timing-test/` is a bootable disk that measures CPU and chip-bus operation
Expand Down
13 changes: 13 additions & 0 deletions packaging/flatpak/cargo-sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,19 @@
"dest": "cargo/vendor/thiserror-impl-2.0.18",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/thread-priority/thread-priority-3.1.0.crate",
"sha256": "6b61f78661ff568c3a69890da32f056c54ba191afae3e41065f39cfe9217fa3c",
"dest": "cargo/vendor/thread-priority-3.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"6b61f78661ff568c3a69890da32f056c54ba191afae3e41065f39cfe9217fa3c\", \"files\": {}}",
"dest": "cargo/vendor/thread-priority-3.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
Expand Down
11 changes: 10 additions & 1 deletion src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ pub struct CpalSink {
}

impl CpalSink {
pub fn new() -> Result<Self> {
/// Build the live cpal output sink. When `realtime_priority` is set, the
/// audio callback thread promotes itself on its first invocation (see
/// [`crate::priority`]); the flag is resolved by the caller from config and
/// the `COPPERLINE_REALTIME_PRIORITY` env var.
pub fn new(realtime_priority: bool) -> Result<Self> {
let host = cpal::default_host();
let device = host
.default_output_device()
Expand Down Expand Up @@ -170,6 +174,11 @@ impl CpalSink {
.build_output_stream(
&config,
move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
// Runs on the cpal-owned audio thread. Latched internally,
// so only the first callback does the scheduling syscall.
if realtime_priority {
crate::priority::promote_audio_thread_once();
}
let chans = channels as usize;
if profile_enabled {
let frames = data.len() / chans;
Expand Down
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ pub struct Emulation {
/// `PacingBudget`. The `COPPERLINE_REAL_PACING_BUDGET` env var overrides
/// this for one run.
pub pacing_budget: PacingBudget,
/// Ask the OS to schedule the latency-critical threads (the wall-clock
/// pacer and the audio callback) above normal, to reduce stutter and audio
/// glitches under host load. Best effort and off by default; see
/// [`crate::priority`]. The `COPPERLINE_REALTIME_PRIORITY` env var
/// overrides this for one run.
pub realtime_priority: bool,
}

/// Real-mode pacing budget model.
Expand Down Expand Up @@ -505,6 +511,7 @@ impl Default for Config {
emulation: Emulation {
power_on: true,
pacing_budget: PacingBudget::Cycles,
realtime_priority: false,
},
chip_ram_bytes: 512 * 1024,
fast_ram_bytes: 0,
Expand Down Expand Up @@ -782,6 +789,9 @@ struct RawEmulation {
speed: Option<String>,
power_on: Option<bool>,
pacing_budget: Option<String>,
/// Best-effort realtime-like thread priority for the pacer and audio
/// threads (default false). See `src/priority.rs`.
realtime_priority: Option<bool>,
}

#[derive(Debug, Default, Deserialize)]
Expand Down Expand Up @@ -922,6 +932,10 @@ impl TryFrom<RawConfig> for Config {
None => defaults.emulation.pacing_budget,
Some(s) => parse_pacing_budget(s)?,
},
realtime_priority: raw
.emulation
.realtime_priority
.unwrap_or(defaults.emulation.realtime_priority),
};
let chip_ram_bytes = match raw.memory.chip.as_deref() {
None => defaults.chip_ram_bytes,
Expand Down
19 changes: 17 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod harddrive;
mod inputrec;
mod inputsched;
mod memory;
mod priority;
mod recorder;
mod romsearch;
mod rtc;
Expand Down Expand Up @@ -909,11 +910,18 @@ fn main() -> Result<()> {
None => None,
};
let floppy = FloppyController::from_config(&cfg.floppy)?;
// Best-effort realtime-like scheduling for the latency-critical threads.
// Resolved once here (env var overrides the config) so the audio sink can
// promote its callback thread and the pacer thread can be raised below.
let realtime_priority = priority::requested(cfg.emulation.realtime_priority);
if realtime_priority {
info!("priority: realtime-like thread scheduling requested (best effort)");
}
let serial = Box::new(StdoutSink::new());
let audio: Box<dyn AudioSink> = if let Some(ref wav_path) = cli.audio_wav {
Box::new(WavSink::new(wav_path)?)
} else if cli.audio_live {
Box::new(CpalSink::new()?)
Box::new(CpalSink::new(realtime_priority)?)
} else {
Box::new(NullSink)
};
Expand Down Expand Up @@ -1058,6 +1066,12 @@ fn main() -> Result<()> {
about_machine_lines(&cfg),
);

// Elevate the thread that is about to run the event loop and the pacer.
// Only when actually pacing to wall-clock time: headless capture advances
// the core unthrottled, so priority buys it nothing.
if realtime_priority && paced {
priority::elevate_pacer_thread();
}
info!(
"entering event loop. {HOST_SHORTCUT_MODIFIER_LABEL}+Q to quit, {HOST_SHORTCUT_MODIFIER_LABEL}+S to screenshot, {HOST_SHORTCUT_MODIFIER_LABEL}+G to capture/release mouse."
);
Expand Down Expand Up @@ -1099,7 +1113,8 @@ fn run_live_audio_profile(secs: f32) -> Result<()> {
"audio profile mode: running Paula DMA to cpal for {:.3}s without window rendering",
secs
);
let audio = Box::new(CpalSink::new()?);
// This diagnostic mode loads no config, so the realtime knob is env-only.
let audio = Box::new(CpalSink::new(priority::requested(false))?);
let mut paula = Paula::new(Box::new(StdoutSink::new()), audio);
paula.set_led_filter_enabled(true);

Expand Down
Loading
Loading