Skip to content

fix(core): the APU clock is region-independent; it ran 0.92% slow on PAL - #316

Merged
doublegate merged 2 commits into
mainfrom
fix/apu-region-independent-clock
Aug 2, 2026
Merged

fix(core): the APU clock is region-independent; it ran 0.92% slow on PAL#316
doublegate merged 2 commits into
mainfrom
fix/apu-region-independent-clock

Conversation

@doublegate

@doublegate doublegate commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Bus::advance_master converts master ticks to SMP base clocks through a fixed rational, and the denominator was 715_909the NTSC master clock — in both regions.

The APU runs from its own 24.576 MHz crystal, so its rate is the same on NTSC and PAL while the master clock's is not (21.477270 vs 21.281370 MHz). Holding the whole ratio fixed therefore made the APU scale with the video clock:

APU base rate
hardware, both regions 2,050,560 Hz
RustySNES, NTSC 2,050,560 Hz
RustySNES, PAL (before) 2,031,850 Hz — 0.92% slow

Both references disagree with the old behaviour, from opposite directions

ares (sfc/system/system.cpp:70-81) region-sets cpuFrequency and leaves apuFrequency = 32040.0 * 768.0 never region-set at all:

information.cpuFrequency = Constants::Colorburst::NTSC * 6.0;   // NTSC
information.cpuFrequency = Constants::Colorburst::PAL  * 4.8;   // PAL
f64 apuFrequency = 32040.0 * 768.0;                             // never region-set

snes9x (apu/apu.cpp:20-23) carries two explicit ratios:

static const int APU_NUMERATOR_NTSC = 15664;  APU_DENOMINATOR_NTSC = 328125;
static const int APU_NUMERATOR_PAL  = 34176;  APU_DENOMINATOR_PAL  = 709379;

Both work out to an APU rate of exactly 1,025,280 Hz, differing only in the master-clock denominator. 709_379 × 30 = 21_281_370 — which is where the new constant comes from.

The fix

SPC_DEN_NTSC / SPC_DEN_PAL, chosen from the PPU's region at the point of use rather than cached in Clock. That is deliberate: the accumulator is already serialized, and a second field agreeing with it is one more thing that can disagree across a region change or a state restore — the trap a per-dot field hit previously.

A stale doc comment had asserted the opposite. sync_region_from_cart said "nothing else in the core depends on which oscillator frequency a real console would use." The SPC accumulator is exactly that one thing, because the APU's crystal is not the master clock's. Corrected rather than left to mislead the next reader; docs/scheduler.md §async-resync and docs/apu.md updated in the same change.

Verification

the_apu_rate_is_region_independent asserts the two divisors differ by exactly the ratio of the two master clocks, observed through emitted DSP samples so it needs no new counter. It passes with the fix and fails when the region-blind behaviour is injected.

NTSC output is byte-identical — only the PAL path moves.

cargo clippy --workspace --all-targets -- -D warnings        clean
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps   clean
cargo build -p rustysnes-core --target thumbv7em-none-eabihf --no-default-features   ok
cargo test -p rustysnes-core                                 98 passed
AccuracySNES harness                                         56 passed

Cross-validation, both images: snes9x: OK (15 known), Mesen2: OK (1 known), ares: OK (3 known), snes9x PAL: OK (15 known), Mesen2 PAL: OK (1 known), 54/54 scenes on both scene hosts, 3 reference(s) agree with the cart.

Found while designing B2.07 (NTSC 60.0988 Hz), which needs an APU-vs-video time ratio and so had to establish what the APU rate actually is.

🤖 Generated with Claude Code

PAL APU timing was approximately 0.92% slow for PAL input because the SMP used the NTSC clock denominator. The change selects the denominator from the current PPU region, which corrects the PAL DSP sample rate. NTSC output remains byte-identical.

The claim is false if PAL still uses SPC_DEN_NTSC, if a region switch or state restore selects the wrong denominator, or if NTSC output changes. Regression tests measure emitted DSP samples.

No AccuracySNES dossier assertions were added or altered. AccuracySNES coverage remains 56 tests against 443 dossier assertions. The coverage denominator did not move.

Documentation records 715,909 for NTSC and 709,379 for PAL.

Copilot AI review requested due to automatic review settings August 1, 2026 22:43
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2392891d-f25a-42d1-b2ac-a97c3d7842df

📥 Commits

Reviewing files that changed from the base of the PR and between afc8137 and 30a0d6f.

📒 Files selected for processing (1)
  • crates/rustysnes-core/src/bus.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: review
  • GitHub Check: build demo + docs
🧰 Additional context used
📓 Path-based instructions (9)
crates/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

crates/**/*.rs: Preserve the master-clock lockstep timing model.
rustysnes-core::Bus owns mutable machine state, and the CPU borrows &mut Bus.
Preserve determinism: seed, ROM, and input must produce bit-identical output.
Treat test ROMs as the behavioral specification; when documentation disagrees with passing ROM behavior, update the documentation.
Keep unsafe confined to existing allowed areas, namely frontend and FFI code, and document every unsafe block with a // SAFETY: comment.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-{cpu,ppu,apu,cart,core}/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Keep core chip implementation changes localized to the owning chip crate and preserve the workspace crate boundaries.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.rs: Use Rust edition 2024 and the toolchain pinned in rust-toolchain.toml (Rust 1.96).
Run cargo fmt --all --check; Rust code must remain rustfmt-compliant.
Run Clippy with cargo clippy --workspace --all-targets -- -D warnings; warnings must not remain.
New public Rust items must have rustdoc because missing_docs is a workspace lint.
Do not run cargo clippy --all-features; scripting and script-wasm are mutually exclusive. Use explicit per-feature jobs instead.

**/*.rs: Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes, netplay messages, Lua or scripting input, or user-supplied paths—outside #[cfg(test)] code. Use typed errors at those boundaries; locally constructed values or values immediately protected by a checked invariant are allowed.
Every new unsafe { ... } block or unsafe fn must have an adjacent // SAFETY: comment naming the relied-on invariant and its guarantor. Unsafe code outside the frontend and FFI shims should additionally be questioned because unsafe_code is a workspace lint.

**/*.rs: Use Rust edition 2024 with the pinned 1.96 toolchain; satisfy workspace pedantic, nursery, missing_docs, and unsafe_code warnings because CI runs with -D warnings. Document every public item.
Keep unsafe code restricted to the frontend and FFI, and include a // SAFETY: justification for each use.
Keep hot paths allocation-free.
Treat rustysnes_core::Bus as the owner of mutable emulator state; the CPU borrows &mut Bus.
Use the master clock at 21477270 Hz as the timing master; advance the scheduler in lockstep and run other chips on their divisors.
Maintain determinism: seed, ROM, and input must produce bit-identical audio/video; frontend rate control must not alter emulation results.
When implementing hardware behavior, pin and run the failing test ROM first; treat test ROMs as the specification.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-core/**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

The core crate must compile for thumbv7em-none-eabihf with --no-default-features; the chip stack must remain no_std.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*.{rs,md}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Chip-behavior changes must update both the chip implementation and the corresponding docs/<subsystem>.md documentation.

A chip change must update both the chip implementation and its corresponding docs/<chip>.md documentation in the same change.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: Do not commit or vendor the generated snesdev_wiki/ mirror; it is gitignored and intended only as a local reference.
Keep commits focused and use Conventional Commits: <type>(<scope>): <subject>, with an imperative subject of at most 72 characters.
Do not use emojis in code, comments, or commit messages.
Before opening a PR, ensure formatting, Clippy, workspace tests, the core embedded build, rustdoc with warnings denied, documentation coverage, and changelog requirements pass.
Ticket completion must be reflected in the relevant to-dos/ sprint file.

**/*: Preserve the one-directional crate graph: chip crates must not depend on one another; rustysnes-core ties them together.
Never commit commercial ROMs; only commit derived screenshots and hashes.
Keep docs/STATUS.md as the authoritative per-subsystem status and update project documentation in the same PR as code changes.
Do not treat RustyNES v2.0 or engine-lineage anchors as project releases.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-*/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, any observable behavior change under crates/rustysnes-<chip>/ must be accompanied by an edit to the matching docs/<chip>.md; a crate change passes without documentation only when it does not alter observable behavior, with the non-behavioral change stated explicitly.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*.{rs,toml}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{rs,toml}: Additive features must be default-off so shipped/native, no_std, and wasm builds remain byte-identical.
Never use or configure --all-features; validate opt-in feature combinations individually as required by the project recipe.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/**

⚙️ CodeRabbit configuration file

crates/**: Emulator core. Hot paths are allocation-free; unsafe requires a // SAFETY: comment
naming the invariant. Any change to save-stated fields needs a FORMAT_VERSION bump and a
docs/adr/0006 bump-log entry. Behavior changes must update the matching docs/<chip>.md
in the same change.

Files:

  • crates/rustysnes-core/src/bus.rs
🔇 Additional comments (2)
crates/rustysnes-core/src/bus.rs (2)

1740-1763: Measure elapsed master ticks before comparing sample counts.

advance_master recursively advances DRAM_REFRESH_CLOCKS. The helper increments n only for outer calls. The NTSC and PAL sample totals therefore cover different elapsed bus.clock.master intervals.

Return elapsed master ticks with each sample total. Normalize both totals by those measured intervals before asserting the ratio.


128-128: LGTM!

Also applies to: 467-473, 810-822, 901-903


Walkthrough

The SPC timing denominator now uses the active PPU region. NTSC keeps its existing divisor, while PAL uses a PAL-specific divisor. A regression test verifies PAL sample-rate scaling. APU and scheduler documentation now describe both denominators.

Changes

APU timing correction

Layer / File(s) Summary
Region-specific SPC timing
crates/rustysnes-core/src/bus.rs
The bus defines separate NTSC and PAL SPC denominators. advance_master selects the denominator from the current PPU region. Region synchronization documentation covers SPC timing.
Regression coverage and timing documentation
crates/rustysnes-core/src/bus.rs, docs/apu.md, docs/scheduler.md, CHANGELOG.md
The regression test compares NTSC and PAL sample counts. The APU and scheduler documentation describe both denominators. The changelog records the correction.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant advance_master
  participant PPU
  participant SPC accumulator
  participant SMP
  advance_master->>PPU: Read active region
  advance_master->>SPC accumulator: Select NTSC or PAL denominator
  SPC accumulator->>SMP: Release APU cycles
Loading

Possibly related PRs

  • doublegate/RustySNES#250: Both changes modify Bus::advance_master scheduling behavior.
  • doublegate/RustySNES#294: Both changes modify bus.rs timing logic, but this change updates the APU divider while the other addresses PPU scanline timing.
🚥 Pre-merge checks | ✅ 8 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title uses valid Conventional Commits syntax and describes the PAL APU timing fix, but its subject is declarative rather than imperative. Rewrite the subject in imperative mood, for example: fix(core): make the APU clock region-independent.
Docs-As-Spec ⚠️ Warning crates/rustysnes-core/src/bus.rs changes PAL advance_master audio timing, but the PR does not edit the required matching docs/core.md (absent in both trees). Add or update docs/core.md in this PR to specify the region-dependent SPC divisor and PAL APU timing behavior.
✅ Passed checks (8 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Changelog Entry ✅ Passed The full PR diff from base 43e5968 to HEAD modifies CHANGELOG.md with an Unreleased Fixed entry for the PAL APU timing correction.
Accuracysnes Bookkeeping ✅ Passed The base-to-HEAD diff changes only CHANGELOG.md, bus.rs, and two documentation files; no test or scene under tests/roms/AccuracySNES/gen/src/ changed.
No Panic On Untrusted Input ✅ Passed The complete PR diff adds no .unwrap(), .expect(), or panic!() calls. The only added assertions are inside #[cfg(test)] code; existing production unwrap handles a fixed local WRAM allocation.
Safety Comment On New Unsafe ✅ Passed The complete PR diff adds no unsafe blocks or unsafe fn declarations; the only changed Rust file is bus.rs, with no unsafe syntax.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rustysnes-core/src/bus.rs`:
- Around line 1737-1764: Update the smp_samples helper to record
bus.clock.master before advancing and return the elapsed master ticks alongside
the drained sample count, using bus.clock.master minus the starting value so
recursive DRAM_REFRESH_CLOCKS are included. Normalize the NTSC and PAL sample
counts by their measured elapsed ticks before comparing the rates and ratio,
rather than comparing raw sample totals.
- Around line 132-142: Unify the APU timing model across the documented sites:
in crates/rustysnes-core/src/bus.rs, docs/scheduler.md, docs/apu.md, and
docs/adr/0001-master-clock-lockstep-scheduler.md, distinguish the nominal 24.576
MHz resonator from the ares model’s 24,606,720 Hz SMP clock and align
accumulator ratios with 68,352/(715,909|709,379) and the region-specific master
clocks. In CHANGELOG.md, correct the Unreleased PAL calculation to approximately
2,031,856 rather than 2,031,850; update every listed site as required, with no
site treated as evidence-only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 75b13ba2-4ace-4bd4-aa1c-19510e2adb49

📥 Commits

Reviewing files that changed from the base of the PR and between 8f19133 and 015e2c8.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • crates/rustysnes-core/src/bus.rs
  • docs/apu.md
  • docs/scheduler.md
📜 Review details
⏰ Context from checks skipped due to timeout. (6)
  • GitHub Check: lint
  • GitHub Check: accuracysnes
  • GitHub Check: test-light
  • GitHub Check: copilot-pull-request-reviewer
  • GitHub Check: build demo + docs
  • GitHub Check: review
🧰 Additional context used
📓 Path-based instructions (14)
docs/**/*.md

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Before changing a subsystem, consult docs/architecture.md, docs/STATUS.md, CONTRIBUTING.md, the relevant subsystem documentation, and applicable ADRs.

New subsystems must add documentation under docs/.

Files:

  • docs/apu.md
  • docs/scheduler.md
**/*.{rs,md}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Chip-behavior changes must update both the chip implementation and the corresponding docs/<subsystem>.md documentation.

A chip change must update both the chip implementation and its corresponding docs/<chip>.md documentation in the same change.

Files:

  • docs/apu.md
  • docs/scheduler.md
  • CHANGELOG.md
  • crates/rustysnes-core/src/bus.rs
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: Do not commit or vendor the generated snesdev_wiki/ mirror; it is gitignored and intended only as a local reference.
Keep commits focused and use Conventional Commits: <type>(<scope>): <subject>, with an imperative subject of at most 72 characters.
Do not use emojis in code, comments, or commit messages.
Before opening a PR, ensure formatting, Clippy, workspace tests, the core embedded build, rustdoc with warnings denied, documentation coverage, and changelog requirements pass.
Ticket completion must be reflected in the relevant to-dos/ sprint file.

**/*: Preserve the one-directional crate graph: chip crates must not depend on one another; rustysnes-core ties them together.
Never commit commercial ROMs; only commit derived screenshots and hashes.
Keep docs/STATUS.md as the authoritative per-subsystem status and update project documentation in the same PR as code changes.
Do not treat RustyNES v2.0 or engine-lineage anchors as project releases.

Files:

  • docs/apu.md
  • docs/scheduler.md
  • CHANGELOG.md
  • crates/rustysnes-core/src/bus.rs
docs/**/*

📄 CodeRabbit inference engine (docs/testing-strategy.md)

Chip crates should exceed 90% unit-test coverage, and each chip should be fuzzable in isolation.

Files:

  • docs/apu.md
  • docs/scheduler.md
docs/**

⚙️ CodeRabbit configuration file

docs/**: Docs are the spec, not a history log. Flag claims that contradict the code, counts that
contradict the generated docs/accuracysnes-coverage.md, and any statement of coverage that
is broader than what the corresponding test actually asserts.

Files:

  • docs/apu.md
  • docs/scheduler.md
**/*.md

⚙️ CodeRabbit configuration file

**/*.md: Docs are the spec, not a changelog. Flag prose that has drifted from the code it describes
rather than style nits. The markdownlint gate is pinned to v0.39.0 via pre-commit —
do not report rules that version does not have (MD060 in particular).

Files:

  • docs/apu.md
  • docs/scheduler.md
  • CHANGELOG.md
CHANGELOG.md

📄 CodeRabbit inference engine (CONTRIBUTING.md)

User-visible changes must be recorded under the [Unreleased] section.

For the full pull request diff against its base branch, modify CHANGELOG.md when user-visible behavior changes, including emulator output, frontend features, CLI flags, public APIs, or AccuracySNES cartridge contents. Do not require it for purely internal changes, tests, comments, or CI configuration.

Files:

  • CHANGELOG.md
crates/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

crates/**/*.rs: Preserve the master-clock lockstep timing model.
rustysnes-core::Bus owns mutable machine state, and the CPU borrows &mut Bus.
Preserve determinism: seed, ROM, and input must produce bit-identical output.
Treat test ROMs as the behavioral specification; when documentation disagrees with passing ROM behavior, update the documentation.
Keep unsafe confined to existing allowed areas, namely frontend and FFI code, and document every unsafe block with a // SAFETY: comment.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-{cpu,ppu,apu,cart,core}/**/*.rs

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Keep core chip implementation changes localized to the owning chip crate and preserve the workspace crate boundaries.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.rs: Use Rust edition 2024 and the toolchain pinned in rust-toolchain.toml (Rust 1.96).
Run cargo fmt --all --check; Rust code must remain rustfmt-compliant.
Run Clippy with cargo clippy --workspace --all-targets -- -D warnings; warnings must not remain.
New public Rust items must have rustdoc because missing_docs is a workspace lint.
Do not run cargo clippy --all-features; scripting and script-wasm are mutually exclusive. Use explicit per-feature jobs instead.

**/*.rs: Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes, netplay messages, Lua or scripting input, or user-supplied paths—outside #[cfg(test)] code. Use typed errors at those boundaries; locally constructed values or values immediately protected by a checked invariant are allowed.
Every new unsafe { ... } block or unsafe fn must have an adjacent // SAFETY: comment naming the relied-on invariant and its guarantor. Unsafe code outside the frontend and FFI shims should additionally be questioned because unsafe_code is a workspace lint.

**/*.rs: Use Rust edition 2024 with the pinned 1.96 toolchain; satisfy workspace pedantic, nursery, missing_docs, and unsafe_code warnings because CI runs with -D warnings. Document every public item.
Keep unsafe code restricted to the frontend and FFI, and include a // SAFETY: justification for each use.
Keep hot paths allocation-free.
Treat rustysnes_core::Bus as the owner of mutable emulator state; the CPU borrows &mut Bus.
Use the master clock at 21477270 Hz as the timing master; advance the scheduler in lockstep and run other chips on their divisors.
Maintain determinism: seed, ROM, and input must produce bit-identical audio/video; frontend rate control must not alter emulation results.
When implementing hardware behavior, pin and run the failing test ROM first; treat test ROMs as the specification.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-core/**/*.rs

📄 CodeRabbit inference engine (CONTRIBUTING.md)

The core crate must compile for thumbv7em-none-eabihf with --no-default-features; the chip stack must remain no_std.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/rustysnes-*/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, any observable behavior change under crates/rustysnes-<chip>/ must be accompanied by an edit to the matching docs/<chip>.md; a crate change passes without documentation only when it does not alter observable behavior, with the non-behavioral change stated explicitly.

Files:

  • crates/rustysnes-core/src/bus.rs
**/*.{rs,toml}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{rs,toml}: Additive features must be default-off so shipped/native, no_std, and wasm builds remain byte-identical.
Never use or configure --all-features; validate opt-in feature combinations individually as required by the project recipe.

Files:

  • crates/rustysnes-core/src/bus.rs
crates/**

⚙️ CodeRabbit configuration file

crates/**: Emulator core. Hot paths are allocation-free; unsafe requires a // SAFETY: comment
naming the invariant. Any change to save-stated fields needs a FORMAT_VERSION bump and a
docs/adr/0006 bump-log entry. Behavior changes must update the matching docs/<chip>.md
in the same change.

Files:

  • crates/rustysnes-core/src/bus.rs
🔇 Additional comments (2)
crates/rustysnes-core/src/bus.rs (1)

465-473: LGTM!

Also applies to: 888-900

docs/apu.md (1)

273-276: LGTM!

Comment thread crates/rustysnes-core/src/bus.rs
Comment thread crates/rustysnes-core/src/bus.rs Outdated
Comment on lines +1737 to +1764
fn smp_samples(region: Region, master_ticks: u64) -> usize {
let mut bus = Bus::new(region);
let mut sink = std::vec::Vec::new();
let mut n = 0;
while n < master_ticks {
bus.advance_master(MASTER_PER_DOT);
n += u64::from(MASTER_PER_DOT);
bus.apu.drain_audio(&mut sink);
}
sink.len()
}

// Many frames, so the ~0.92% difference is far larger than the one-sample quantisation.
let ntsc = smp_samples(Region::Ntsc, 425_568 * 40);
let pal = smp_samples(Region::Pal, 425_568 * 40);
assert!(
pal > ntsc,
"PAL must produce MORE audio per master tick than NTSC — its master clock is slower \
(21.281370 vs 21.477270 MHz) while the APU crystal is the same, so one master tick is \
worth more APU. Got NTSC {ntsc} samples, PAL {pal}"
);
#[allow(clippy::cast_precision_loss)]
let ratio = (pal as f64) / (ntsc as f64);
// 21_477_270 / 21_281_370 = 1.0092053...; the tolerance is one accumulator tick either way.
assert!(
(ratio - 1.009_205).abs() < 0.000_05,
"the two divisors must differ by exactly the ratio of the two master clocks; got \
{ratio} from NTSC {ntsc} samples, PAL {pal}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Measure elapsed master ticks before comparing sample counts.

Bus::advance_master recursively advances DRAM_REFRESH_CLOCKS at each scanline. smp_samples increments n only by MASTER_PER_DOT. The test interval therefore includes master ticks that n does not count.

This input runs long enough to trigger DRAM refresh repeatedly. The raw pal / ntsc ratio combines the APU rate with unequal elapsed master-tick counts. Return bus.clock.master - start and normalize each sample count by its measured elapsed ticks.

Proposed fix
-        fn smp_samples(region: Region, master_ticks: u64) -> usize {
+        fn smp_samples(region: Region, master_ticks: u64) -> (usize, u64) {
             let mut bus = Bus::new(region);
             let mut sink = std::vec::Vec::new();
-            let mut n = 0;
-            while n < master_ticks {
+            let start = bus.clock.master;
+            while bus.clock.master - start < master_ticks {
                 bus.advance_master(MASTER_PER_DOT);
-                n += u64::from(MASTER_PER_DOT);
                 bus.apu.drain_audio(&mut sink);
             }
-            sink.len()
+            (sink.len(), bus.clock.master - start)
         }

-        let ntsc = smp_samples(Region::Ntsc, 425_568 * 40);
-        let pal = smp_samples(Region::Pal, 425_568 * 40);
+        let (ntsc, ntsc_ticks) = smp_samples(Region::Ntsc, 425_568 * 40);
+        let (pal, pal_ticks) = smp_samples(Region::Pal, 425_568 * 40);
         assert!(
             pal > ntsc,
             "PAL must produce MORE audio per master tick than NTSC ..."
         );
         #[allow(clippy::cast_precision_loss)]
-        let ratio = (pal as f64) / (ntsc as f64);
+        let ratio = (pal as f64 / pal_ticks as f64) / (ntsc as f64 / ntsc_ticks as f64);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn smp_samples(region: Region, master_ticks: u64) -> usize {
let mut bus = Bus::new(region);
let mut sink = std::vec::Vec::new();
let mut n = 0;
while n < master_ticks {
bus.advance_master(MASTER_PER_DOT);
n += u64::from(MASTER_PER_DOT);
bus.apu.drain_audio(&mut sink);
}
sink.len()
}
// Many frames, so the ~0.92% difference is far larger than the one-sample quantisation.
let ntsc = smp_samples(Region::Ntsc, 425_568 * 40);
let pal = smp_samples(Region::Pal, 425_568 * 40);
assert!(
pal > ntsc,
"PAL must produce MORE audio per master tick than NTSC — its master clock is slower \
(21.281370 vs 21.477270 MHz) while the APU crystal is the same, so one master tick is \
worth more APU. Got NTSC {ntsc} samples, PAL {pal}"
);
#[allow(clippy::cast_precision_loss)]
let ratio = (pal as f64) / (ntsc as f64);
// 21_477_270 / 21_281_370 = 1.0092053...; the tolerance is one accumulator tick either way.
assert!(
(ratio - 1.009_205).abs() < 0.000_05,
"the two divisors must differ by exactly the ratio of the two master clocks; got \
{ratio} from NTSC {ntsc} samples, PAL {pal}"
fn smp_samples(region: Region, master_ticks: u64) -> (usize, u64) {
let mut bus = Bus::new(region);
let mut sink = std::vec::Vec::new();
let start = bus.clock.master;
while bus.clock.master - start < master_ticks {
bus.advance_master(MASTER_PER_DOT);
bus.apu.drain_audio(&mut sink);
}
(sink.len(), bus.clock.master - start)
}
// Many frames, so the ~0.92% difference is far larger than the one-sample quantisation.
let (ntsc, ntsc_ticks) = smp_samples(Region::Ntsc, 425_568 * 40);
let (pal, pal_ticks) = smp_samples(Region::Pal, 425_568 * 40);
assert!(
pal > ntsc,
"PAL must produce MORE audio per master tick than NTSC — its master clock is slower \
(21.281370 vs 21.477270 MHz) while the APU crystal is the same, so one master tick is \
worth more APU. Got NTSC {ntsc} samples, PAL {pal}"
);
#[allow(clippy::cast_precision_loss)]
let ratio = (pal as f64 / pal_ticks as f64) / (ntsc as f64 / ntsc_ticks as f64);
// 21_477_270 / 21_281_370 = 1.0092053...; the tolerance is one accumulator tick either way.
assert!(
(ratio - 1.009_205).abs() < 0.000_05,
"the two divisors must differ by exactly the ratio of the two master clocks; got \
{ratio} from NTSC {ntsc} samples, PAL {pal}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustysnes-core/src/bus.rs` around lines 1737 - 1764, Update the
smp_samples helper to record bus.clock.master before advancing and return the
elapsed master ticks alongside the drained sample count, using bus.clock.master
minus the starting value so recursive DRAM_REFRESH_CLOCKS are included.
Normalize the NTSC and PAL sample counts by their measured elapsed ticks before
comparing the rates and ratio, rather than comparing raw sample totals.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes RustySNES core scheduling so the APU (SPC700/S-DSP) runs at the same wall-clock rate on both NTSC and PAL by making the async-resync denominator region-dependent (master clock differs by region, APU crystal does not).

Changes:

  • Make the SPC fractional-clock denominator depend on the PPU region (NTSC vs PAL) inside Bus::advance_master.
  • Update scheduler/APU documentation to reflect the region-dependent denominator and the prior PAL slowdown.
  • Add a core regression test asserting the NTSC/PAL divisor ratio matches the master-clock ratio, and document the fix in the changelog.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
docs/scheduler.md Updates async-resync documentation to describe region-dependent SPC divisor and rationale.
docs/apu.md Updates APU integration docs to describe the new region-dependent denominator behavior.
crates/rustysnes-core/src/bus.rs Implements region-specific SPC divisor selection in the master-tick loop and adds a regression test.
CHANGELOG.md Documents the PAL APU rate fix and verification strategy in Unreleased.

///
/// **The APU's oscillator is region-independent and the master clock's is not**, so this ratio is
/// the one place in the core where which console a real machine is matters. The APU runs from its
/// own 24.576 MHz crystal at a fixed `2_050_560` Hz base rate in both regions; the master clock is
Comment thread docs/scheduler.md
the SPC domain is bit-deterministic (`docs/adr/0004`).

**The denominator is the one place in the core where which console a real machine is matters, and
it was region-blind until `v1.29.0`.** The APU runs from its own 24.576 MHz crystal, so its rate is
Comment thread CHANGELOG.md
- **The APU ran 0.92% slow on PAL: its clock divisor was pinned to the NTSC master rate.**
`Bus::advance_master` converts master ticks to SMP base clocks through a fixed rational, and the
denominator was `715_909` — the NTSC master clock — in **both** regions. The APU runs from its own
24.576 MHz crystal, so its rate is the same on NTSC and PAL while the master clock's is not;
Bus::advance_master converts master ticks to SMP base clocks through a
fixed rational, and the denominator was 715_909 -- the NTSC master clock
-- in BOTH regions. The APU runs from its own 24.576 MHz crystal, so its
rate is the same on NTSC and PAL while the master clock's is not; holding
the whole RATIO fixed therefore made the APU scale with the video clock.
On PAL that is 21_281_370 x 68352/715909 = 2_031_850 base Hz against
hardware's 2_050_560.

Both references disagree with the old behaviour, from opposite
directions. ares (sfc/system/system.cpp) region-sets cpuFrequency and
never region-sets apuFrequency at all. snes9x (apu/apu.cpp) carries two
explicit ratios, 15664/328125 and 34176/709379, which both work out to an
APU rate of exactly 1,025,280 Hz and differ ONLY in the master-clock
denominator -- 709_379 x 30 is 21_281_370, which is where the new
constant comes from.

The divisor is chosen from the PPU's region at the point of use rather
than cached in Clock: the accumulator is already serialized, and a second
field agreeing with it is one more thing that can disagree across a
region change or a state restore.

A stale doc comment had asserted the opposite -- sync_region_from_cart
said "nothing else in the core depends on which oscillator frequency a
real console would use". The SPC accumulator is exactly that one thing;
the comment is corrected rather than left to mislead.
docs/scheduler.md §async-resync and docs/apu.md updated in the same
change.

NTSC output is byte-identical. the_apu_rate_is_region_independent asserts
the two divisors differ by exactly the ratio of the two master clocks,
observed through emitted DSP samples so it needs no new counter; it fails
when the region-blind behaviour is injected. The AccuracySNES battery
(56 harness tests) and both images still cross-validate three ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate
doublegate force-pushed the fix/apu-region-independent-clock branch from 015e2c8 to afc8137 Compare August 1, 2026 23:05
…per tick

Bot review, all three accepted.

The region match sat inside the per-sub-tick loop -- the emulator's
hottest path -- when nothing inside that loop can change the region. It
is now resolved once per call.

The test drained the audio FIFO once per DOT across ~4.2M iterations. It
drains once per frame instead: ~640 samples a frame is far inside the
16,384-entry FIFO, so nothing is lost, and both regions still run over
the same number of master ticks so the comparison is unchanged
(APU-per-master-tick, not frame length).

SPC_DEN_NTSC's summary now carries the unreduced 21_477_270 the way
SPC_DEN_PAL's already carried 21_281_370.

Re-verified: the test still fails when the region-blind behaviour is
injected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

All three accepted.

  • The per-tick match is hoisted. It sat inside the per-sub-tick loop when nothing in that loop can change the region — resolved once per advance_master call now, with the reason recorded so it does not drift back in.
  • The test drains once per frame rather than once per dot. ~640 samples a frame is far inside the 16,384-entry FIFO, so nothing is lost; both regions still run over the same number of master ticks, so what is compared is APU-per-master-tick and not frame length.
  • SPC_DEN_NTSC's summary now carries the unreduced 21_477_270, matching what SPC_DEN_PAL's already said.

Re-verified after the changes: the test still fails when the region-blind behaviour is injected, so the tightening did not cost the assertion.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR fixes PAL APU emulation timing by making the SPC700 master-clock denominator region-dependent (709,379 for PAL vs 715,909 for NTSC) so the APU runs at its native 24.576 MHz crystal frequency regardless of video region.

Blocking issues

None found.

Suggestions

  • crates/rustysnes-core/src/bus.rs:818-821: advance_master evaluates self.ppu.region() on every master tick (~21.4M times per emulated second). While the PR explicitly removes cached region fields to eliminate stale state across serialization/restores, calling through self.ppu.region() inside the inner loop may block LLVM loop optimizations across mutable helper calls in advance_master. Benchmark release builds to confirm zero throughput regression compared to caching spc_den directly within Clock during region updates.
  • crates/rustysnes-core/src/bus.rs:1757-1759: the_apu_rate_is_region_independent assumes bus.apu.drain_audio(&mut sink) yields non-zero DSP samples from default power-on state over advance_master iterations. Add an explicit assertion that ntsc > 0 prior to calculating ratio to prevent potential silent NaN/divide-by-zero failures if default APU startup/silence behavior changes.

Nitpicks

  • docs/scheduler.md:520: The documentation references v1.29.0 as the target version for this change; ensure this tag aligns with the planned release version.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

@doublegate
doublegate merged commit f195314 into main Aug 2, 2026
16 checks passed
@doublegate
doublegate deleted the fix/apu-region-independent-clock branch August 2, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants