Skip to content

feat(apu): implement the SMP wait states; E3.09 measures them - #313

Merged
doublegate merged 3 commits into
mainfrom
feat/apu-smp-wait-states
Aug 1, 2026
Merged

feat(apu): implement the SMP wait states; E3.09 measures them#313
doublegate merged 3 commits into
mainfrom
feat/apu-smp-wait-states

Conversation

@doublegate

@doublegate doublegate commented Aug 1, 2026

Copy link
Copy Markdown
Owner

The gap between two tables

$F0 bits 4-5 (external) and 6-7 (internal) select a clock divider for the SMP, nominally {2, 4, 8, 16}. Dividers of 8 and 16 are glitchy on real silicon: the CPU ends up consuming 10 and 20 clocks per opcode cycle while the timers still advance by 8 and 16. ares and bsnes carry the same comment (sfc/smp/timing.cpp) — "sometimes the SMP will run far slower than expected, other times … the SMP will deadlock until the system is reset. The timers are not affected by this and advance by their expected values."

What the cart found

RustySNES parsed both selectors into Io::external_wait / Io::internal_wait, saved them, restored them — and nothing downstream ever read either one. Eighth instance of the dead-config defect class in this repo, and the first found by the test cartridge rather than by grep.

rustysnes-apu now carries:

table governs
SMP_CYCLE_WAIT = [2,4,10,20] SmpBus::cycles, the recorded micro-op plan, the S-DSP catch-up — all real base clocks
SMP_TIMER_WAIT = [2,4,8,16] the three timers, and nothing else

with ares' SMP::wait address classification: idle cycles, $00F0-$00FF, and the IPL ROM while $F1 bit 7 keeps it mapped take the internal selector; everything else external. $F4-$F7 reads keep their split halved steps.

At the reset selector both tables read SMP_WAIT, so every program that leaves $F0 alone — which is every commercial driver — is byte-identical to before. A unit test pins that explicitly.

E3.09 reads the gap as a ratio

A wait selector changes clocks-per-opcode-cycle, not an instruction's cycle count, so the same loop is the same number of opcode cycles in both phases and only the timer-per-cycle rate moves. Over a fixed 48-pass poll loop with T0DIV = 1:

model phase B / phase A
selectors unimplemented 1x
one table for both (cycle table used for the timers) 5x
the two documented tables 4x

So the row separates the two ways of having the feature, not merely its absence. Both wrong models were injected and both fail on code 2.

Three $F0 bits are not the subject and must not move: the timer halt (0), the RAM write enable (1) and the global timer enable (3). Phase B is $AA, not a bare $A0 — clearing bit 1 would drop every MOV dp,A in the poll loop and the row would read as a timing finding when it was a store that never happened. Both selectors are set together because the loop mixes external accesses (fetches) with internal ones ($FD).

Cross-validation

Mesen2 and ares both pass it. snes9x fails it off the same missing case 0xf0 that already costs it E3.08 and E3.10 — three rows from one absent switch case — so SNES9X_KNOWN_FAILURES goes 14 → 15 with that citation. 3-vs-1, snes9x the documented outlier.

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

Verification

cargo fmt --check, workspace clippy at -D warnings, RUSTDOCFLAGS="-D warnings" cargo doc, the no_std thumbv7em build, cargo test --workspace (68 suites), and the 56-test AccuracySNES harness suite all pass. docs/apu.md gains the selector model in the same change.

Coverage 352 → 353 of 443 (299 on-cart + 54 scenes); battery 340 tests, 100% on-cart.

🤖 Generated with Claude Code

Implements $F0-selected SMP wait states. CPU and DSP timing uses [2, 4, 10, 20]; timers use [2, 4, 8, 16]. Address classification selects internal or external waits, and write timing occurs before side effects. The observable change is selector-dependent CPU and timer progression. The claim is false if $F0 selectors do not change timing as specified, or if reset behavior changes when $F0 remains unchanged.

Adds AccuracySNES dossier assertion E3.09. Coverage increases from 352/443 to 353/443 assertions. The test detects the defect when selector 2 fails to produce approximately four times the timer ticks of normal $F0 timing.

Updates APU documentation, cross-validation results, and the SNES9X known-failure list.

… them

$F0 bits 4-5 and 6-7 select a clock divider for the SMP, nominally
{2, 4, 8, 16} -- but 8 and 16 are glitchy on real silicon and the CPU
consumes 10 and 20 clocks per opcode cycle while the timers still
advance by 8 and 16. ares and bsnes carry the same comment
(sfc/smp/timing.cpp): "the timers are not affected by this and advance
by their expected values." Two tables, and the gap between them is what
the new row measures.

RustySNES parsed both selectors into Io::external_wait /
Io::internal_wait, saved them, restored them -- and nothing downstream
ever read either one. The eighth instance of the dead-config defect
class, and the first the cart found rather than grep.

rustysnes-apu now carries SMP_CYCLE_WAIT = [2,4,10,20] for the CPU (and
so for the recorded micro-op plan and the S-DSP catch-up, which are real
base clocks) against SMP_TIMER_WAIT = [2,4,8,16] for the timers alone,
with ares' SMP::wait address classification: idle cycles, $00F0-$00FF
and a mapped IPL ROM take the internal selector, everything else the
external one. At the reset selector both tables read SMP_WAIT, so every
program that leaves $F0 alone -- which is every commercial driver -- is
byte-identical to before.

E3.09 reads the gap as a ratio the program can see: a selector changes
clocks-per-cycle, not an instruction's cycle count, so the same loop is
the same number of opcode cycles either way and only the timer-per-cycle
rate moves. Over a fixed 48-pass poll loop timer 0 ticks 4x as often at
selector 2 as at selector 0. Both wrong models were injected and both
fail on code 2: no wait states reads 1x, charging the CPU's glitchy 10
to the timers as well reads 5x. The row separates the two ways of having
the feature, not merely its absence.

Mesen2 and ares both pass it; snes9x fails it off the same missing
`case 0xf0` that already costs it E3.08 and E3.10, so
SNES9X_KNOWN_FAILURES goes 14 -> 15 with that citation.

Coverage 352 -> 353 of 443 (299 on-cart + 54 scenes), battery 340 tests
at 100% on-cart, three references agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 20:11
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@doublegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 69f1bd4a-3f8e-4b72-9740-71ed5bf069e5

📥 Commits

Reviewing files that changed from the base of the PR and between 0ce5aea and 52c5498.

⛔ Files ignored due to path filters (6)
  • docs/accuracysnes-coverage.md is excluded by !docs/accuracysnes-coverage.md and included by docs/**
  • tests/roms/AccuracySNES/ERROR_CODES.md is excluded by !tests/roms/AccuracySNES/ERROR_CODES.md and included by tests/**
  • tests/roms/AccuracySNES/SOURCE_CATALOG.tsv is excluded by !**/*.tsv, !tests/roms/AccuracySNES/SOURCE_CATALOG.tsv and included by tests/**
  • tests/roms/AccuracySNES/asm/tests_group_a.s is excluded by !tests/roms/AccuracySNES/asm/tests_group_a.s and included by tests/**
  • tests/roms/AccuracySNES/build/accuracysnes-pal.sfc is excluded by !tests/roms/AccuracySNES/build/** and included by tests/**
  • tests/roms/AccuracySNES/build/accuracysnes.sfc is excluded by !tests/roms/AccuracySNES/build/** and included by tests/**
📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/rustysnes-apu/src/dsp.rs
  • crates/rustysnes-test-harness/tests/accuracysnes.rs
  • docs/STATUS.md
  • docs/accuracysnes-plan.md
  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs

Walkthrough

The APU now applies selector- and address-dependent CPU and timer wait-state timing. Bus, recording, save-state validation, documentation, and AccuracySNES coverage were updated. The new E3.09 test verifies selector 2 produces approximately four times the timer ticks.

Changes

SMP wait-state timing

Layer / File(s) Summary
Timing tables and access classification
crates/rustysnes-apu/src/lib.rs
Separate CPU and timer wait tables classify internal, IPL ROM, I/O, idle, and ordinary ARAM accesses. Saved-plan validation accepts all valid table-derived durations.
Bus and recording timing integration
crates/rustysnes-apu/src/lib.rs
Live and recorded reads, writes, and idle operations use address-aware timing. Port reads retain split waits, and recorded writes apply timing before side effects.
Timing behavior tests
crates/rustysnes-apu/src/lib.rs, tests/roms/AccuracySNES/gen/src/tests/apu.rs, tests/roms/AccuracySNES/gen/src/dossier.rs
Tests cover reset compatibility, selector divergence, access classification, and the E3.09 selector-2 timer ratio.
Documentation and cross-validation reporting
docs/apu.md, CHANGELOG.md, scripts/accuracysnes/crossval.sh, docs/STATUS.md, docs/accuracysnes-plan.md
Documentation describes the timing tables and access rules. AccuracySNES coverage and known-failure records were updated.

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

Sequence Diagram(s)

sequenceDiagram
  participant AccuracySNESTest
  participant RustySNESAPU
  participant Timer0
  AccuracySNESTest->>RustySNESAPU: Set normal $F0 timing
  RustySNESAPU->>Timer0: Accumulate timer ticks
  AccuracySNESTest->>RustySNESAPU: Set selector 2
  RustySNESAPU->>Timer0: Accumulate timer ticks
  AccuracySNESTest->>AccuracySNESTest: Assert approximately 4x ticks
Loading

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 10
✅ Passed checks (10 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses the valid feat(apu): subject format, uses imperative mood, has no trailing period, and describes the SMP wait-state implementation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 against origin/main includes a 27-line CHANGELOG.md [Unreleased] entry documenting the SMP wait-state behavior and E3.09 AccuracySNES coverage.
Docs-As-Spec ✅ Passed The APU crate changes observable $F0 wait timing, selector classification, and timer behavior; the same PR edits docs/apu.md to specify those tables and classifications.
Accuracysnes Bookkeeping ✅ Passed Against base fe3ea97, E3.09 is registered and mapped to E3.09; all six required artifacts changed, no scene changed, and coverage counts increase by one consistently.
No Panic On Untrusted Input ✅ Passed The PR adds no .unwrap(), .expect(), or panic!() calls. Existing APU unwraps are inside #[cfg(test)]; production save-state reads propagate errors with ?.
Safety Comment On New Unsafe ✅ Passed The parent-to-HEAD diff adds no unsafe block or unsafe fn; changed APU code also has #![forbid(unsafe_code)].

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

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

Implements the SPC700/SMP $F0 wait selectors in rustysnes-apu, including the documented “glitch” where opcode-cycle cost (CPU) diverges from timer advancement (timers), and adds an AccuracySNES row (E3.09) that measures the expected 4x ratio to distinguish correct behavior from common incorrect models.

Changes:

  • Add separate wait tables for CPU vs timers and apply ares-style internal/external address classification (incl. IPL-mapped region handling).
  • Add AccuracySNES E3.09 test program + documentation/error-code/catalog updates and regenerate emitted artifacts.
  • Update docs/cross-validation metadata and project status/changelog coverage counts.

Reviewed changes

Copilot reviewed 12 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
crates/rustysnes-apu/src/lib.rs Implements split CPU/timer wait-state model, address classification, recording/stepping changes, and adds unit tests
tests/roms/AccuracySNES/gen/src/tests/apu.rs Adds E3.09 generator-side test definition and helper program logic
tests/roms/AccuracySNES/gen/src/dossier.rs Adds dossier mapping entry for E3.09
tests/roms/AccuracySNES/asm/tests_group_a.s Regenerated ROM assembly including E3.09 test entry + program bytes
tests/roms/AccuracySNES/SOURCE_CATALOG.tsv Regenerated catalog entry for E3.09
tests/roms/AccuracySNES/ERROR_CODES.md Regenerated error-code documentation for E3.09
scripts/accuracysnes/crossval.sh Documents snes9x divergence and increments known-failures count
docs/apu.md Documents $F0 selector model and the two-table rationale
docs/STATUS.md Updates AccuracySNES coverage and battery counts
docs/accuracysnes-plan.md Updates counts and notes the newly found defect/row
docs/accuracysnes-coverage.md Updates coverage totals and E3 subgroup coverage list
CHANGELOG.md Adds release notes entry for E3.09 and SMP wait-state implementation

Comment on lines +292 to +302
const fn wait_index(&self, address: Option<u16>) -> usize {
let internal = match address {
None => true,
Some(a) => a & 0xFFF0 == 0x00F0 || (a >= 0xFFC0 && self.iplrom_enable),
};
(if internal {
self.internal_wait
} else {
self.external_wait
}) as usize
}

@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: 3

🤖 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-apu/src/lib.rs`:
- Around line 292-314: Update Io::load_state to validate external_wait and
internal_wait immediately after reading them, rejecting any value above 3 with
the established typed SaveStateError::Invalid pattern used for Apu::load_state
instead of allowing invalid values to reach wait_index and wait_clocks.

In `@docs/accuracysnes-plan.md`:
- Around line 16-17: Update the SNES9X recorded divergence count on Line 20 of
the accuracy plan from 14 to 15, matching SNES9X_KNOWN_FAILURES in crossval.sh
and keeping the documentation metrics synchronized.

In `@docs/apu.md`:
- Around line 362-363: Update the public timing descriptions to make reset
compatibility conditional on $F0 remaining unchanged: in docs/apu.md lines
362-363 and CHANGELOG.md lines 28-29, replace the universal claim about every
commercial driver with wording that applies only to programs that leave $F0
unchanged.
🪄 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: bfe11eeb-a03d-47f1-897f-f5c0ccf01545

📥 Commits

Reviewing files that changed from the base of the PR and between fe3ea97 and 0ce5aea.

⛔ Files ignored due to path filters (6)
  • docs/accuracysnes-coverage.md is excluded by !docs/accuracysnes-coverage.md and included by docs/**
  • tests/roms/AccuracySNES/ERROR_CODES.md is excluded by !tests/roms/AccuracySNES/ERROR_CODES.md and included by tests/**
  • tests/roms/AccuracySNES/SOURCE_CATALOG.tsv is excluded by !**/*.tsv, !tests/roms/AccuracySNES/SOURCE_CATALOG.tsv and included by tests/**
  • tests/roms/AccuracySNES/asm/tests_group_a.s is excluded by !tests/roms/AccuracySNES/asm/tests_group_a.s and included by tests/**
  • tests/roms/AccuracySNES/build/accuracysnes-pal.sfc is excluded by !tests/roms/AccuracySNES/build/** and included by tests/**
  • tests/roms/AccuracySNES/build/accuracysnes.sfc is excluded by !tests/roms/AccuracySNES/build/** and included by tests/**
📒 Files selected for processing (8)
  • CHANGELOG.md
  • crates/rustysnes-apu/src/lib.rs
  • docs/STATUS.md
  • docs/accuracysnes-plan.md
  • docs/apu.md
  • scripts/accuracysnes/crossval.sh
  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: lint
  • GitHub Check: accuracysnes
  • GitHub Check: test-light
  • GitHub Check: copilot-pull-request-reviewer
  • GitHub Check: build demo + docs
🧰 Additional context used
📓 Path-based instructions (18)
**/*

📄 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:

  • scripts/accuracysnes/crossval.sh
  • docs/apu.md
  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • docs/accuracysnes-plan.md
  • docs/STATUS.md
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
  • CHANGELOG.md
  • crates/rustysnes-apu/src/lib.rs
scripts/accuracysnes/**

⚙️ CodeRabbit configuration file

scripts/accuracysnes/**: The cross-validation harness: the same AccuracySNES image is run on snes9x (through a
libretro host in C) and on Mesen2 (through its test runner and a Lua script), and their
verdicts are compared with the cart's. Its integrity is the whole argument for the
battery, so flag anything that could make a reference appear to agree — a verdict parsed
loosely, a missing-file path that degrades to success, a scene comparison that skips
rather than fails when the golden is absent. A known reference divergence belongs in
SNES9X_KNOWN_FAILURES with a source citation, never in a widened match.

Files:

  • scripts/accuracysnes/crossval.sh
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/accuracysnes-plan.md
  • docs/STATUS.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
  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • docs/accuracysnes-plan.md
  • docs/STATUS.md
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
  • CHANGELOG.md
  • crates/rustysnes-apu/src/lib.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/accuracysnes-plan.md
  • docs/STATUS.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/accuracysnes-plan.md
  • docs/STATUS.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/accuracysnes-plan.md
  • docs/STATUS.md
  • CHANGELOG.md
**/*.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:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
  • crates/rustysnes-apu/src/lib.rs
tests/roms/AccuracySNES/gen/src/**/*

📄 CodeRabbit inference engine (Custom checks)

For the full pull request diff against its base branch, when a test or scene is added or removed under tests/roms/AccuracySNES/gen/src/, verify its dossier.rs::MAP entry, all required regenerated artifacts, and matching count changes in docs/accuracysnes-plan.md. Artifact presence must be judged from the path-filter exclusion list, not from unreadable contents.

Files:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.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:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
  • crates/rustysnes-apu/src/lib.rs
tests/roms/AccuracySNES/gen/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

Rebuild AccuracySNES after any change to gen/ or asm/; never hand-edit generated asm/tests_group_a.s or asm/scenes.s.

Files:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
tests/roms/AccuracySNES/gen/src/**

⚙️ CodeRabbit configuration file

tests/roms/AccuracySNES/gen/src/**: This generates a hardware-accuracy test cartridge. Judge each test by whether it can
distinguish the behavior it names from the alternatives, not by whether it passes.

Flag, specifically:

  • Vacuity. An assertion whose expected value is also what a broken or absent
    implementation produces (zero, "unchanged", "not $FF") needs a paired control assertion
    that would fail on that implementation. Say which alternative goes uncaught.
  • Overstated doc comments. The prose above a test is a claim about what it validates.
    If it names behaviors the emitted program does not exercise, or asserts a rationale that
    is not true of the code, that is a defect even though the test passes.
  • Shared state. OAM, CGRAM, VRAM and the S-DSP registers are not reset between tests. A
    test that does not establish its own starting conditions may be measuring the previous
    one; look for an earlier test that leaves the relevant state dirty.
  • Timing-marginal reads. Reading a register a few cycles after disturbing it, or
    asserting on a value that is still moving, produces a verdict that flips when unrelated
    code shifts. Prefer a settle, a disarm, or a provably stationary value.
  • Scanline geometry. Line 0 is a blanking line; the V counter's low byte aliases on a
    312-line PAL frame; the visible height is 224 or 239 depending on overscan. Constants
    derived from any of these deserve a second look.
  • Duplicate coverage. dossier.rs::MAP must not claim an assertion another test already
    implements. There is a build gate for this, but flag it in review too.

Files:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
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-apu/src/lib.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-apu/src/lib.rs
crates/rustysnes-apu/**/*.rs

📄 CodeRabbit inference engine (crates/rustysnes-apu/CLAUDE.md)

Before changing the SPC700 core, DSP mixing, or APU/main-CPU synchronization, read the specification at ../../docs/apu.md.

Files:

  • crates/rustysnes-apu/src/lib.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-apu/src/lib.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-apu/src/lib.rs
🧠 Learnings (5)
📚 Learning: 2026-07-21T02:10:49.581Z
Learnt from: doublegate
Repo: doublegate/RustySNES PR: 190
File: tests/roms/AccuracySNES/gen/src/tests/ppu.rs:0-0
Timestamp: 2026-07-21T02:10:49.581Z
Learning: For any AccuracySNES tests that perform a runtime measurement investigation using writes to $2137 and $4201, do not reuse measurement/slot indices that may already be owned by another test. Before using a slot, verify it is unused (e.g., via an on-cart probe/readback that confirms the slot contains no prior test result). Then independently record $213F immediately before and immediately after each $2137/$4201 operation, so the test can attribute changes to its own operation and avoid cross-test interference.

Applied to files:

  • tests/roms/AccuracySNES/gen/src/dossier.rs
  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
📚 Learning: 2026-07-21T01:34:22.909Z
Learnt from: doublegate
Repo: doublegate/RustySNES PR: 189
File: docs/accuracysnes-plan.md:0-0
Timestamp: 2026-07-21T01:34:22.909Z
Learning: When reviewing the AccuracySNES documentation in docs/accuracysnes-*.md (notably docs/accuracysnes-plan.md vs the generated docs/accuracysnes-coverage.md), treat the reported metrics as intentionally non-equivalent: the battery test count and dossier assertion coverage are not interchangeable. Do not infer one count/coverage from the other during review (e.g., one test may contain multiple assertions, and multiple tests may contribute to a single assertion/row such as E6.02).

Applied to files:

  • docs/accuracysnes-plan.md
📚 Learning: 2026-07-21T05:22:58.848Z
Learnt from: doublegate
Repo: doublegate/RustySNES PR: 197
File: docs/accuracysnes-plan.md:598-600
Timestamp: 2026-07-21T05:22:58.848Z
Learning: In the AccuracySNES documentation under `docs/`, when an assertion exists in the research dossier but cannot be measured/verified by the current cartridge timing test, distinguish the dossier assertion from test measurability: keep the original hardware assertion (and any contribution to the coverage denominator) intact, withdraw/stop using the specific test coverage only if the sources cannot decompose the required CPU-cycle timing into bus vs internal components, and mark the row as not measurable using the `[NOT CART-MEASURABLE ...]` annotation with links to the corresponding plan section (e.g., `docs/accuracysnes-plan.md` §A5.20) and the related roadmap/ticket (e.g., `to-dos/ROADMAP.md` ticket `T-06-A`). Ensure the documentation/coverage reporting treats the row as uncovered rather than removing or redefining the assertion.

Applied to files:

  • docs/accuracysnes-plan.md
📚 Learning: 2026-07-21T06:21:34.629Z
Learnt from: doublegate
Repo: doublegate/RustySNES PR: 198
File: docs/accuracysnes-plan.md:0-0
Timestamp: 2026-07-21T06:21:34.629Z
Learning: When reviewing slot allocation for SNES/ROM measurement channels in generator-driven Rust code, account for slots assigned via generation-time computed writers (e.g., slots derived from formulas like `slot_base = 8 + index * 2`) rather than only literal `record(...)` calls. Trace the computed writer’s full emitted slot range and verify there are no collisions with other opcodes/channels that may use different slot ranges after earlier conflict resolution.

Applied to files:

  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
📚 Learning: 2026-07-22T06:12:00.703Z
Learnt from: doublegate
Repo: doublegate/RustySNES PR: 201
File: tests/roms/AccuracySNES/gen/src/tests/apu.rs:1508-1538
Timestamp: 2026-07-22T06:12:00.703Z
Learning: When working on APU/voice timing in these AccuracySNES test sources, treat NTSC vs PAL differences as a first-class constraint. In particular, do not change `Voice::settle` (or any settling/code-size/timing logic it affects) unless you cross-validate against both regions (NTSC and PAL), because timing/polling phase shifts can cause regressions that may appear as PAL-only test failures. For ROM-specific expectations like `E7.13`, keep the intentionally chosen ENVX range (e.g., `0x68..=0x7C`) unless you re-validate that the absorbed post-KON timing variation still matches across both regions.

Applied to files:

  • tests/roms/AccuracySNES/gen/src/tests/apu.rs
🪛 LanguageTool
docs/STATUS.md

[style] ~244-~244: Try using a descriptive adverb here.
Context: ...cs/adr/0013), kept as separate columns on purpose (docs/accuracysnes-coverage.md`, regen...

(ON_PURPOSE_DELIBERATELY)

🔇 Additional comments (10)
docs/apu.md (1)

342-361: LGTM!

Also applies to: 364-367

CHANGELOG.md (1)

14-27: LGTM!

Also applies to: 31-40

scripts/accuracysnes/crossval.sh (1)

95-103: LGTM!

Also applies to: 178-178

docs/STATUS.md (1)

26-27: LGTM!

Also applies to: 244-244

docs/accuracysnes-plan.md (1)

18-19: LGTM!

Also applies to: 21-22

crates/rustysnes-apu/src/lib.rs (2)

676-681: 🗄️ Data Integrity & Integration

Confirm whether this save-state validation widening needs a FORMAT_VERSION bump.

This change widens the accepted range for a saved plan step's base_clocks from the old restriction (SMP_WAIT or its half) to 0 < base_clocks <= MAX_PLAN_BASE_CLOCKS. No new field is added and the byte layout is unchanged, but the referenced ADR ties save-state validation changes in this exact PR to a version-bump requirement.

Confirm whether docs/adr/0006 and FORMAT_VERSION need an entry for this change, or whether the team's position is that widening validation for an existing field (without a layout change) is exempt.

As per path instructions for crates/**: "Any change to save-stated fields needs a FORMAT_VERSION bump and a docs/adr/0006 bump-log entry," and the referenced ADR notes: "Because this PR changes APU/SMP timing state and save-state validation, preserve deterministic save/load behavior."

Also applies to: 703-706

Source: Path instructions


49-81: LGTM!

Also applies to: 770-782, 878-893, 902-904, 938-946, 1064-1071, 1086-1089, 1102-1102, 1150-1195

tests/roms/AccuracySNES/gen/src/tests/apu.rs (2)

150-150: LGTM!

Also applies to: 8449-8523, 8533-8577, 8579-8601


8524-8532: 🎯 Functional Correctness

No slot collision exists for slots 268 or 269.

			> Likely an incorrect or invalid review comment.
tests/roms/AccuracySNES/gen/src/dossier.rs (1)

273-273: LGTM!

Comment on lines +292 to +314
const fn wait_index(&self, address: Option<u16>) -> usize {
let internal = match address {
None => true,
Some(a) => a & 0xFFF0 == 0x00F0 || (a >= 0xFFC0 && self.iplrom_enable),
};
(if internal {
self.internal_wait
} else {
self.external_wait
}) as usize
}

/// The `(cpu, timer)` base-clock pair one access costs, halved for the split `$F4-$F7` reads.
///
/// Returned as a pair rather than resolved separately at each call site because the whole point
/// of the glitch is that the two numbers differ; computing them apart invites one of them to be
/// derived from the wrong table.
const fn wait_clocks(&self, halve: bool, address: Option<u16>) -> (u32, u32) {
let idx = self.wait_index(address);
let shift = if halve { 1 } else { 0 };
(SMP_CYCLE_WAIT[idx] >> shift, SMP_TIMER_WAIT[idx] >> shift)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Out-of-range $F0 wait selector from a save state panics on indexing.

wait_index casts self.internal_wait/self.external_wait directly to usize and wait_clocks indexes SMP_CYCLE_WAIT/SMP_TIMER_WAIT (4-element arrays) with that index. The live write path always masks these fields with & 0x03, so normal execution never produces an out-of-range value. Io::load_state does not: it assigns self.external_wait = s.read_u8()?; and self.internal_wait = s.read_u8()?; directly, with no bounds check.

A save-state file with either byte set above 3 makes wait_index return an index of up to 255. The first subsequent APU access calls wait_clocks, which indexes SMP_CYCLE_WAIT[idx]/SMP_TIMER_WAIT[idx] and panics.

Before this PR these fields were parsed but never consulted, so the missing validation in Io::load_state was harmless. This PR's wait_index/wait_clocks are the first code to read them as array indices, turning that gap into a crash on untrusted save-state input.

Add the bounds check in Io::load_state, consistent with how Apu::load_state already rejects an out-of-range base_clocks (lines 703-706) with a typed error instead of a panic.

The coding guideline states: "Do not introduce .unwrap(), .expect(), or panic!() on untrusted external input—such as ROM/save-state bytes... Use typed errors at those boundaries."

🛡️ Proposed fix in `Io::load_state` (outside the selected range, around line 334)
self.external_wait = s.read_u8()?;
self.internal_wait = s.read_u8()?;
if self.external_wait > 3 || self.internal_wait > 3 {
    return Err(SaveStateError::Invalid(alloc::format!(
        "APU wait selector external={} internal={} exceeds the 2-bit field range (0..=3)",
        self.external_wait, self.internal_wait
    )));
}
🤖 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-apu/src/lib.rs` around lines 292 - 314, Update
Io::load_state to validate external_wait and internal_wait immediately after
reading them, rejecting any value above 3 with the established typed
SaveStateError::Invalid pattern used for Apu::load_state instead of allowing
invalid values to reach wait_index and wait_clocks.

Source: Coding guidelines

Comment thread docs/accuracysnes-plan.md Outdated
Comment on lines +16 to +17
| Tests | **340** (scoring + golden vectors + region SKIP per image) — *tests, not assertions; see the note below the table* |
| Assertion coverage | **353 of 443** dossier assertions — **299 on-cart** + **54 rendered scenes**, kept as separate columns (`docs/accuracysnes-coverage.md`) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Synchronize the SNES9X divergence count.

The changed metrics row is paired with Line 20, which still says SNES9X has 14 recorded divergences. scripts/accuracysnes/crossval.sh now sets SNES9X_KNOWN_FAILURES to 15 after E3.09.

Change Line 20 to 15.

As per path instructions, docs are the spec and cross-file claims must remain synchronized.

🤖 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 `@docs/accuracysnes-plan.md` around lines 16 - 17, Update the SNES9X recorded
divergence count on Line 20 of the accuracy plan from 14 to 15, matching
SNES9X_KNOWN_FAILURES in crossval.sh and keeping the documentation metrics
synchronized.

Source: Path instructions

Comment thread docs/apu.md
Comment on lines +362 to +363
At the reset selector both tables read `SMP_WAIT`, so this is byte-identical to the previous
single-number model for every program that leaves `$F0` alone — which is every commercial driver.

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 | 🟡 Minor | ⚡ Quick win

Keep reset compatibility conditional in both public descriptions.

A nonzero wait selector in $F0 changes timing. The implementation guarantees byte-identical behavior only when $F0 remains at reset. Both entries add the unsupported claim that every commercial driver leaves $F0 unchanged.

  • docs/apu.md#L362-L363: replace the universal claim with “for programs that leave $F0 unchanged.”
  • CHANGELOG.md#L28-L29: apply the same conditional wording.
Proposed wording
-for every program that leaves `$F0` alone — which is every commercial driver.
+for programs that leave `$F0` unchanged.

As per path instructions, docs are the spec, so public timing descriptions must match the conditional behavior.

📝 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
At the reset selector both tables read `SMP_WAIT`, so this is byte-identical to the previous
single-number model for every program that leaves `$F0` alone — which is every commercial driver.
At the reset selector both tables read `SMP_WAIT`, so this is byte-identical to the previous
single-number model for programs that leave `$F0` unchanged.
📍 Affects 2 files
  • docs/apu.md#L362-L363 (this comment)
  • CHANGELOG.md#L28-L29
🤖 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 `@docs/apu.md` around lines 362 - 363, Update the public timing descriptions to
make reset compatibility conditional on $F0 remaining unchanged: in docs/apu.md
lines 362-363 and CHANGELOG.md lines 28-29, replace the universal claim about
every commercial driver with wording that applies only to programs that leave
$F0 unchanged.

Source: Path instructions

doublegate and others added 2 commits August 1, 2026 16:35
…them

The $00F0-$00FF register block wins every SPC read of those addresses,
so "a write also lands in RAM" cannot be checked by writing and reading
back. It needs a second reader of APU RAM that skips the register
decode, and the S-DSP is one: it fetches BRR sample data straight out of
ARAM.

A nine-byte block written THROUGH the register block at $00F7 ends
exactly at $00FF, never reaching the directory at $0100, and clears the
two addresses that would break the run -- $F0 (TEST, the wait states and
the RAM-write enable) and $F1 (CONTROL, which would unmap the IPL ROM
and strand release_to_ipl). Three of its nine bytes land on $FD-$FF,
which are read-only, so for those the shadow is the only thing a write
could have affected.

The assertion is EQUALITY against a control voice playing a
byte-identical copy in ordinary RAM, not "the shadow read back
non-zero". What sits under the register block at power-on is undefined
and one reference randomises APU RAM, so a non-zero reading could be
luck; nine bytes decoding to the control's exact OUTX cannot be. The
control doubles as the guard.

Injecting `address & 0xFFF0 != 0x00F0` into both bus write paths fires
code 2 with the guard passing.

Two setup faults the guard caught rather than letting the row pass on
two zeroes: KOF is a level register a previous program can leave set,
and KON must be HELD -- it is examined once every two output samples
(E8.01), so a write immediately followed by a clear is cancelled before
the DSP ever looks.

Coverage 353 -> 354 of 443 (300 on-cart + 54 scenes), battery 341 tests
at 100% on-cart, three references agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by a dead-config sweep of the APU while writing E3.13: the field
is written at $4C and serialized, and nothing downstream reads it.
`keylatch` is what actually keys a voice on -- misc29/misc30 clear it
against `_keyon` once per sample, which is what makes a second $4C write
cancel a pending key-on (E8.01).

Not an accuracy bug: the behaviour is correct because it comes from the
latch. But a reader who mistook this field for the authoritative flag
would reintroduce the "keys on for as long as the bit is set" bug the
latch exists to prevent -- and that is exactly what silenced both voices
on E3.13's first run, from the cart side.

Kept rather than removed: it is part of the serialized layout, so
deleting it is a save-state format change and not worth one here.

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

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR implements the SPC700/SMP hardware wait states controlled by register $F0 bits 4–7 using separate clock lookup tables for CPU cycle timing ({2, 4, 10, 20}) and timer increments ({2, 4, 8, 16}), alongside corresponding AccuracySNES test additions (E3.09, E3.13) and documentation updates.

Blocking issues

  • crates/rustysnes-apu/src/lib.rs:291-299: Io::wait_index casts self.internal_wait and self.external_wait directly to usize without masking (& 3) or bounds checking. If a corrupted save-state or raw field mutation loads a value >= 4 into either field, calls to Io::wait_clocks will panic when indexing SMP_CYCLE_WAIT[idx] and SMP_TIMER_WAIT[idx]. Per the project style guide, untrusted input (such as save-state fields) must never cause a panic in the core.

Suggestions

  • crates/rustysnes-apu/src/lib.rs:297: Mask the selector fields in wait_index with & 3 (e.g. (if internal { self.internal_wait } else { self.external_wait } & 3) as usize) so array access is guaranteed safe even if invalid state is loaded.
  • crates/rustysnes-apu/src/lib.rs:703: base_clocks > MAX_PLAN_BASE_CLOCKS allows any value up to 20 during save-state deserialization, but valid base_clocks values (including halved accesses) can only ever be {1, 2, 4, 5, 10, 20}. Consider validating against valid step clock values rather than only checking the upper bound.

Nitpicks

  • crates/rustysnes-apu/src/lib.rs:309: if halve { 1 } else { 0 } can be simplified to usize::from(halve).

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

@doublegate
doublegate merged commit 48d2842 into main Aug 1, 2026
16 checks passed
@doublegate
doublegate deleted the feat/apu-smp-wait-states branch August 1, 2026 21:34
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