Skip to content

fix(ai): an unprogrammed AI_DACRATE must not stop the DAC (R-16) - #205

Merged
doublegate merged 5 commits into
mainfrom
fix/ai-dac-default-rate
Jul 30, 2026
Merged

fix(ai): an unprogrammed AI_DACRATE must not stop the DAC (R-16)#205
doublegate merged 5 commits into
mainfrom
fix/ai-dac-default-rate

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Motivation

AI_STATUS.FULL could latch and never clear, so a game polling it for a free audio DMA slot spun forever.

Found by root-causing World Driver Championship — the one title the boot-path census in #202 newly implicated: 176,085 RDP commands under hle_boot, zero under real_pif_boot.

Measured, not guessed

The dominant retiring instruction under real-PIF (DC/WB latch, one-cycle granularity):

0x8007a8a0  LUI  t6, 0xA450      ; t6 = 0xA4500000 -- the AI register block
0x8007a8a4  LW   a0, 0xC(t6)     ; a0 = AI_STATUS (0xA450000C)     n=1081  <-- dominant
0x8007a8b0  AND  t7, a0, at      ; test bit 31 (FULL)
0x8007a8b4  BEQ  t7, zero, +3    ; leaves the loop only when FULL is CLEAR

Then — counting transitions rather than sampling the bit, which is the sticky-register lesson this same ledger row learned the hard way on Cause.ExcCode:

FULL set FULL clear transitions
hle_boot 234 66 48 — drains repeatedly
real_pif_boot 270 30 1 — latched at ~frame 30, never cleared again

One instantaneous FULL=1 sample would have proved nothing; both boots show it set at an arbitrary instant.

Mechanism

recompute_rate mapped an unprogrammed AI_DACRATE to sample_rate = 0period_ticks() == 0tick() returned before emit_sample. And emit_sample is the only place a drained transfer is retired:

if self.dma_count > 0 && self.dma_len[0] == 0 {
    self.dma_count -= 1;   // <-- unreachable when the DAC is stopped

So a stopped DAC makes the two-deep FIFO unable to advance, and FULL (dma_count > 1) becomes permanent. WDC queues two buffers and polls FULL before programming the DAC, landing exactly in that window.

The fix invents nothing — and the source matters

Hardware has no stopped-DAC state to model: the DAC counter runs off the video clock from power-on whatever AI_DACRATE holds. ares (ISC — vendorable, therefore readable under ref-proj/README.md, unlike Angrylion) makes the same choice structurally:

auto AI::power(bool reset) -> void { dac.frequency = 44100; dac.period = ...; }
auto AI::main()  -> void { while(Thread::clock < 0) { sample(); ... } }

Its sample() contains the byte-for-byte equivalent retirement block and runs from power-on unconditionally.

So an unprogrammed AI_DACRATE now falls back to DEFAULT_DAC_HZ = 44_100, taken from ares and labeled a modeling default, not a measured value — the register's reset value is not documented in anything this project mirrors, so no rate can be derived for that window. Nothing observable should depend on the exact number, since every title programs the register before it plays anything; the constant's rustdoc says so, and says that if a future output does depend on it, that dependency is the bug.

The old zero-gate was avoiding a real failure — dac_rate == 0 computing video_clock / 1 ≈ 48 MHz and flooding the sink. A default rate avoids both that and the latch.

Audio::new() had to change too, and the reason is worth recording: it initialized sample_rate: 0 literally, and recompute_rate only runs on a DACRATE or region write — so the first version of this fix left a machine that never programmed the AI with a stopped DAC anyway. The defect survived its own fix until the constructor matched. Caught by the new test failing.

Measured effect

before after
WDC RDP commands (real_pif_boot) 0 120,015
WDC scan-out 0x0 625×237
FULL transitions / 300 frames 1 46
Workspace tests 810 pass 810 pass

The Phase-4 golden PCM stream is unaffected — which was the main regression risk, since this changes when the DAC emits.

Tests

  • New: full_clears_even_when_dacrate_was_never_programmed — queues two buffers with no AI_DACRATE write and asserts FULL clears. Mutation-checked: restoring either half of the fix (the recompute_rate branch or the constructor) turns it red.
  • Corrected, not deleted: set_region_before_dacrate_keeps_rate_zero asserted the rate was exactly 0 — which over-specified its own stated purpose. Its comment says it exists to stop a ~48 MHz rate being fabricated, and that protection is preserved as an order-of-magnitude bound (0 < rate < 100_000) plus a sink-flood bound, in the renamed set_region_before_dacrate_does_not_fabricate_the_video_clock_rate. The old exact assertion is what encoded the bug as a requirement.

Explicitly NOT claimed

WDC now renders but ends its run in a B -1 self-loop at 0x8000_28C0 — a different halt, reached after 120k commands. This closes the AI livelock, not that title.

n64-systemtest impact: none. The suite has zero AI coverage, so the count stays at 90. This fix is validated by ares' structure, the mutation-checked unit test, and WDC's measured unblocking — not by the oracle. Ledger R-16 says so in those words.

A corpus-wide census is running to measure whether any other silent title was blocked on the same latch; the result will be posted here and folded into R-16 before merge.

Gates run locally

cargo fmt --all --check · cargo clippy --workspace --all-targets -- -D warnings · cargo test --workspace (810) · RUSTDOCFLAGS="-D warnings" cargo doc · no_std thumbv7em · markdownlint · check_no_roms.sh · check_en_us.sh — all green.

🤖 Generated with Claude Code

`AI_STATUS.FULL` could latch and never clear, so a game polling it for a free
audio DMA slot spun forever.

Found by root-causing World Driver Championship, the one title the boot-path
census newly implicated: 176,085 RDP commands under `hle_boot` and ZERO under
`real_pif_boot`. Measured rather than guessed -- the dominant retiring
instruction under real-PIF is

    0x8007a8a0  LUI  t6, 0xA450      ; the AI register block
    0x8007a8a4  LW   a0, 0xC(t6)     ; AI_STATUS                    <- dominant
    0x8007a8b0  AND  t7, a0, at      ; test bit 31 (FULL)
    0x8007a8b4  BEQ  t7, zero, +3    ; exits only when FULL is clear

and counting FULL *transitions* rather than sampling the bit -- the sticky-
register lesson from this ledger row's own Cause.ExcCode mistake -- settles it:
over 300 frames the flag transitions 48 times under HLE and exactly ONCE under
real-PIF, latching at ~frame 30 and never clearing again.

Mechanism: `recompute_rate` mapped an unprogrammed `AI_DACRATE` to
`sample_rate = 0`, `period_ticks()` to 0, and `tick()` then returned BEFORE
`emit_sample` -- which is the only place a drained transfer is retired. A
stopped DAC therefore makes the two-deep FIFO unable to advance, and
`FULL` (`dma_count > 1`) becomes permanent. WDC queues two buffers and polls
FULL before programming the DAC, so it sat exactly in that window.

The fix invents nothing. Hardware has no stopped-DAC state to model -- the DAC
counter runs off the video clock from power-on whatever `AI_DACRATE` holds --
and ares (ISC, vendorable, so readable per ref-proj/README.md) makes the same
choice structurally: `AI::power()` sets `dac.frequency = 44100` and `AI::main()`
calls `sample()` unconditionally, so its equivalent retirement block runs from
power-on. `DEFAULT_DAC_HZ = 44_100` is taken from there and is labeled a
MODELING DEFAULT, not a measured value: `AI_DACRATE`'s reset value is not
documented in anything this project mirrors. Nothing observable should depend on
the exact number, because every title programs the register before it plays
anything.

The old zero-gate was avoiding a real failure -- `dac_rate == 0` computing
`video_clock / 1` ~= 48 MHz and flooding the sink -- and a default rate avoids
both that and the latch.

`Audio::new()` had to change too, and the reason is worth recording: it
initialized `sample_rate: 0` literally, and `recompute_rate` only runs on a
DACRATE or region write, so the first version of this fix left a machine that
never programmed the AI with a stopped DAC anyway. The defect survived its own
fix until the constructor matched.

Measured effect: WDC under `real_pif_boot` goes 0 -> 120,015 RDP commands and
from `scanout 0x0` to a real 625x237 frame; FULL transitions 1 -> 46. Full
workspace suite 810 pass, so the Phase-4 golden PCM stream is unaffected.

Pinned by `full_clears_even_when_dacrate_was_never_programmed`, mutation-checked
(restoring either half of the fix turns it red). One pre-existing test was
CORRECTED rather than deleted: `set_region_before_dacrate_keeps_rate_zero`
asserted the rate was exactly 0, which over-specified its own stated purpose --
its comment says it exists to prevent a ~48 MHz rate, and that protection is
kept as an order-of-magnitude bound in the renamed
`set_region_before_dacrate_does_not_fabricate_the_video_clock_rate`.

Still open and NOT claimed fixed: WDC now renders but ends its run in a `B -1`
self-loop at 0x8000_28C0, a different halt reached after 120k commands. This
closes the AI livelock, not that title.

n64-systemtest impact: none -- the suite has no AI coverage, so the count stays
at 90. Validated by ares' structure, the unit test, and WDC's unblocking.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
no_std thumbv7em, markdownlint, check_no_roms, check_en_us -- all green.
@coderabbitai

coderabbitai Bot commented Jul 30, 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: 047da33c-9665-41c4-9cc0-8daeeb3822b6

📥 Commits

Reviewing files that changed from the base of the PR and between 58ff847 and 2ce4485.

📒 Files selected for processing (1)
  • crates/rustyn64-audio/src/lib.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an AI_STATUS.FULL latch/livelock where unprogrammed audio settings could stall progress.
    • When AI_DACRATE is unprogrammed, audio now uses a default non-zero rate (instead of a zero “stopped” rate), allowing FIFO advancement and clearing FULL.
  • Documentation

    • Updated audio and AI accuracy documentation with the revised AI_STATUS.FULL analysis and measured behaviour on the affected boot path.
    • Consolidated a related graphics-vector comment correction into the main Unreleased → Fixed section.
  • Tests

    • Adjusted timing/throughput expectations to reflect the default-rate behaviour rather than silence.

Walkthrough

The audio model uses a 44.1 kHz fallback when AI_DACRATE is unprogrammed, allowing FIFO transfers to retire and AI_STATUS.FULL to clear. Tests and documentation cover the behaviour, while accuracy records and changelog entries document the fix and measured World Driver Championship results.

Changes

AI audio DAC fallback

Layer / File(s) Summary
Default DAC rate and FIFO retirement
crates/rustyn64-audio/src/lib.rs
Initialisation and rate recomputation use the 44.1 kHz fallback, with tests covering bounded emission and AI_STATUS.FULL clearing without an AI_DACRATE write.
Audio fallback specification
docs/audio.md
Documents fallback-rate operation, DMA retirement constraints, modelling caveats, provenance, and the restriction against deriving the rate from the video clock.
Accuracy and release records
docs/accuracy-ledger.md, docs/residuals/R-18.md, CHANGELOG.md
Records the livelock diagnosis, measured WDC outcomes, corrected rendering conclusions, and consolidated vector-comment entry.

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

Sequence Diagram(s)

sequenceDiagram
  participant AudioNew
  participant AudioRate
  participant FIFO
  participant AIStatus
  AudioNew->>AudioRate: Initialise unprogrammed DAC rate
  AudioRate->>FIFO: Apply DEFAULT_DAC_HZ
  FIFO->>AIStatus: Retire queued transfer
  AIStatus-->>FIFO: Clear FULL
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 8 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is related, but it is declarative rather than imperative, which violates the Conventional Commits rule. Rewrite it in imperative form, e.g. 'fix(ai): prevent an unprogrammed AI_DACRATE from stopping the DAC'.
Docs-As-Spec Sync ⚠️ Warning Observable rustyn64-audio behaviour changed, but git diff --name-only shows only crates/rustyn64-audio/src/lib.rs; the required docs/audio.md companion edit is absent. Add or include the matching docs/audio.md update for the audio behaviour change, or explain in the PR body why no docs change is needed.
✅ Passed checks (8 passed)
Check name Status Explanation
Description check ✅ Passed The description matches the AI audio deadlock fix and related documentation/test updates.
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.
Oracle Number Is Stated ✅ Passed CHANGELOG.md says n64-systemtest was not measured and stays at 90; docs/STATUS.md confirms 90 is the current oracle figure.
Changelog Entry For User-Visible Changes ✅ Passed PASS: CHANGELOG.md has an [Unreleased]→Fixed entry for the user-visible AI_STATUS.FULL audio fix, and the vector-comment correction is listed there too.
Measured, Never Tuned ✅ Passed PASS: no new untethered constant/timing value. DEFAULT_DAC_HZ pre-existed and is ledgered in R-16; this patch only reroutes new() through recompute_rate() and adds a guard.
Unsafe Stays Out Of The Chip Crates ✅ Passed No AGENTS rule breach: crates/rustyn64-audio keeps #![forbid(unsafe_code)], and the PR diff adds no unsafe syntax outside rustyn64-frontend.

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

Adopts the Antigravity review on #205. No blocking issues; three items.

1. `dac_rate == 0` conflates "never programmed" with "software wrote 0".
   CORRECT, and confirmed against the oracle: ares keeps the two apart —
   `AI::power()` sets 44100 while its AI_DACRATE write honors a literal zero
   (`dac.frequency = max(1, system.videoFrequency() / (io.dacRate + 1))`,
   ares/n64/ai/io.cpp:61), so a written 0 asks for a ~48 MHz DAC and ares gives
   it.

   NOT fixed here, deliberately. Telling the two apart needs a
   `dac_rate_programmed` flag, which adds a field to a serialized struct and so
   changes the save-state layout (ADR 0005) — an announced-in-advance change,
   not one to make in passing while fixing an unrelated livelock. It is also
   unobservable: no title requests a ~48 MHz DAC. Recorded in R-16 and in a code
   comment, with the exact ares line fidelity would require, rather than left as
   an undocumented conflation.

2. Raw register indices and bitmasks in the new test. REJECTED with reason: the
   crate has no symbolic register constants and every existing test in the module
   uses raw indices (`ai.write_reg(1, 0x40)`), so the new test matches the
   surrounding convention. Introducing named constants is worth doing, but as one
   change across the whole module rather than in two lines of a bug-fix PR, where
   it would read as unrelated churn.

3. Assert the exact constant rather than only a bound. ADOPTED, kept alongside
   the bound, because the two catch different things: the equality pins the
   WIRING (that `recompute_rate` reads the named constant instead of an inlined
   literal that could drift), while the bound pins the PROPERTY and is what
   catches the failure class the test exists for — a rate derived from the video
   clock rather than an audio clock.

Also records the corpus census result, which is negative and easy to omit: the
66-title census is UNCHANGED after the fix at 29 render / 24 silent, so no other
silent title shared this latch. That census boots through `hle_boot`, where WDC
already rendered, so it cannot show the improvement it was launched to measure —
foreseeable before the run, and stated rather than dropped.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
markdownlint, check_no_roms, check_en_us — all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the Antigravity review

1. dac_rate == 0 conflates "never programmed" with an explicit write of 0. CORRECT — and confirmed against the oracle rather than accepted on argument.

You're right, and ares proves it. Its reset state and its written-zero state are genuinely different:

// ai/ai.cpp  -- reset
auto AI::power(bool reset) -> void { dac.frequency = 44100; ... }

// ai/io.cpp:61  -- an AI_DACRATE write, honoring a literal zero
dac.frequency = max(1, system.videoFrequency() / (io.dacRate + 1));

So a written 0 asks for a ~48 MHz DAC and ares gives it, while power-on is 44.1 kHz. My branch collapses both onto the default.

Not fixed here, deliberately, and for a reason the diff doesn't show. Telling the two apart needs a dac_rate_programmed flag — which adds a field to a serialized struct and therefore changes the save-state layout (ADR 0005). This repo treats that as an announced-in-advance change, not something to slip into a bug fix for an unrelated livelock. It is also unobservable in practice: no title requests a ~48 MHz DAC.

So it is now ledgered in R-16 and stated in a code comment, with the exact ares line fidelity would require, rather than left as an undocumented conflation. That is the trade this project's ledger exists to make explicit — a known simplification recorded beats a quiet one, and beats a save-state break taken unilaterally.

2. Raw register indices and bitmasks in the new test. REJECTED, with reason.

The crate has no symbolic register constants, and every existing test in the module uses raw indices — e.g. status_reports_busy_and_full does ai.write_reg(1, 0x40) and ai.status() & (1 << 31). The new test matches the surrounding convention, which is the standing instruction here ("write code that reads like the surrounding code").

Introducing named offsets is a genuine improvement and worth doing — but as one change across the whole module, not as two lines inside a bug-fix PR where it would read as unrelated churn and obscure the actual fix. Noted as a follow-up rather than dismissed.

3. Assert the exact constant, not only a bound. ADOPTED — kept alongside the bound.

Both now, because they catch different failures:

assert_eq!(ai.sample_rate(), Audio::DEFAULT_DAC_HZ, ...);          // pins the WIRING
assert!(ai.sample_rate() > 0 && ai.sample_rate() < 100_000, ...);  // pins the PROPERTY

The equality pins that recompute_rate reads the named constant rather than an inlined literal that could later drift from it. The bound pins the property, and it is the one that catches the failure class this test exists for: any future rate derived from the video clock rather than an audio clock. Dropping the bound for the equality would have made the test tautological with the implementation.


Corpus census result — negative, and reported rather than dropped

I said in the PR body I'd post this. The 66-title census re-run after the fix is unchanged: 29 render, 24 issue zero RDP commands — exactly the pre-fix figures. No other silent title was blocked on this latch.

Worth being blunt about a mistake in how I framed that measurement: the committed census boots through hle_boot, where WDC was already rendering 176,085 commands, so that census cannot show the improvement it was launched to measure. It only answers the separate question of whether anything else shared the latch. Foreseeable before starting a 50-minute run; recorded in R-16 because "a fix landed and the headline number didn't move" is the shape of result most likely to be quietly omitted.

A real_pif_boot census — the one that can actually show WDC rejoining the rendering set — is running now, and I'll post it before merging.

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

🤖 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/rustyn64-audio/src/lib.rs`:
- Around line 215-229: Update docs/audio.md to document the AI reset model
represented by DEFAULT_DAC_HZ: use 44,100 Hz while AI_DACRATE is unprogrammed,
allowing the transfer FIFO to retire despite the DAC having no stopped state.
Clearly scope this as a modelling fallback for draining silence before software
programs AI_DACRATE, not a measured or observable hardware reset value.
- Around line 448-451: Update the sample-rate calculation in the register-write
handling around the video_clock and dac_rate fields so a programmed AI_DACRATE
value of literal zero computes video_clock / (0 + 1), rather than selecting
DEFAULT_DAC_HZ. Track the distinction between an unprogrammed register and an
explicitly written zero using existing state or an internal sentinel, and
preserve or explicitly migrate serialized save-state data if the state
representation changes.
- Around line 429-447: Replace the hardware-reset assertion in the DAC fallback
comments near the audio retirement logic with explicitly labelled modelling
rationale, identifying ares as reference-model evidence and primary hardware
reset semantics as undocumented. Update docs/accuracy-ledger.md at lines 413-413
to preserve the evidence trail while distinguishing ares behavior from hardware
evidence, and update CHANGELOG.md at lines 23-26 to describe the fallback as a
modelled default rather than established hardware behavior.
- Around line 243-248: Synchronize the unset-rate documentation and test naming
with the initialized DEFAULT_DAC_HZ behavior. Update the references around
sample_rate, period_ticks, tick, and idle_tick_emits_nothing_before_dacrate so
they no longer describe an unset DAC as zero or stopped, and accurately document
the first-tick anchoring rule. Preserve the existing implementation behavior.
- Around line 716-724: Update the test around the initial ai.tick call so the
scheduler is primed before measuring emitted samples, ensuring the first tick’s
anchor behavior cannot make the assertion vacuous. Then assert that drain()
produces a non-zero count within the expected default-rate range, while
retaining the upper bound that guards against 48 MHz-rate flooding.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5c8503ad-5985-4df9-82fe-6cb4b93da9b6

📥 Commits

Reviewing files that changed from the base of the PR and between 7ccb341 and 147e709.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • crates/rustyn64-audio/src/lib.rs
  • docs/accuracy-ledger.md

Comment thread crates/rustyn64-audio/src/lib.rs
Comment thread crates/rustyn64-audio/src/lib.rs Outdated
Comment thread crates/rustyn64-audio/src/lib.rs Outdated
Comment thread crates/rustyn64-audio/src/lib.rs
Comment thread crates/rustyn64-audio/src/lib.rs
…etracts a claim

The 66-title census under `real_pif_boot` came back **29 render / 24 zero-command**,
against 29/25 before the fix. `hle_boot` is also 29/24, so the two paths now agree
EXACTLY and no title is boot-path sensitive in that partition any more. The sole
regression the #202 census found is closed.

RETRACTION, and it matters more than the census: #205 and R-16 said WDC gains "a
real 625x237 frame". That was wrong. The scratch probe printed
`scanout_scaled`'s DIMENSIONS and never counted content, and I read dimensions as
a picture -- R-18's lit-pixel lesson run in reverse, inferring a frame from
geometry instead of from what is in it.

Measured properly: WDC issues 175,815 RDP commands (from 0, near-parity with
176,085 under HLE) and lights ZERO pixels at every 60-frame sample. The VI is
genuinely programmed -- the single-shot 625x237 establishes that, so it is a real
black frame, not a blanked VI -- and R-18 already recorded the same shape under
HLE: "45 commands and exactly one distinct value (0x0001) -- it clears and draws
nothing."

So the correct claim is narrow: the AI livelock is removed and WDC's real-PIF
behavior now MATCHES its HLE behavior, submitting a full command stream and
rasterizing black on both. It does not render, and this fix never made it render.
Why the zero-command count moves while the render count does not: WDC clears the
1,000-command floor and fails the 1,000-lit-pixel floor, which is the two-term
rule working as designed.

The 24-title silent cohort is UNCHANGED and still needs per-title root-causing.
What closed here is the 25th.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
markdownlint, check_no_roms, check_en_us -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

The real_pif_boot census — and a retraction

I said I would post this before merging. Two results: the fix does what it claims, and a claim in the PR body above is wrong and is retracted.

The census

render zero-command
hle_boot (before & after) 29 24
real_pif_boot before this fix 29 25 (+ WDC)
real_pif_boot after this fix 29 24

The two boot paths now agree exactly. World Driver Championship was the only title real_pif_boot made strictly worse — the finding #202's census surfaced — and it is out of the silent cohort. No title is boot-path sensitive in the render/silent partition any more.

Retraction: WDC does not gain "a real 625×237 frame"

The PR body says it does. That is wrong.

World Driver Championship    rdp=175815    lit=0

175,815 RDP commands (from 0 — near-exact parity with its 176,085 under HLE) and zero lit pixels at every 60-frame sample.

The error: my scratch probe printed scanout_scaled's dimensions and never counted content, and I read the dimensions as a picture. That is R-18's lit-pixel lesson run in reverse — that row spent weeks unlearning "many lit pixels means it renders", and here I inferred a frame from geometry instead of from what was in it.

The VI is genuinely programmed, so this is a real black frame rather than a blanked VI — the single-shot 625×237 establishes that much. And R-18 already recorded the same shape for this title under HLE: "World Driver Championship 45 commands and exactly one distinct value (0x0001) — it clears and draws nothing."

So the correct claim, stated narrowly

The AI livelock is removed, and WDC's real_pif_boot behavior now matches its hle_boot behavior: it submits a full command stream and rasterizes black on both paths. It does not render, and this fix never made it render.

Why the zero-command count moves while the render count does not: WDC clears the 1,000-command floor and fails the 1,000-lit-pixel floor. That is the two-term rule working exactly as designed — the rule exists so "submits commands" and "produces a picture" cannot be confused, and here it stopped them being confused.

The 24-title silent cohort is unchanged and still needs per-title root-causing. What closed here is the 25th.

Both corrections are in docs/accuracy-ledger.md §R-16, docs/residuals/R-18.md (whose index row for WDC now reads CLOSED, with the reason), and the CHANGELOG — the retraction recorded beside the original claim rather than replacing it, per the append-only rule.

@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 `@docs/accuracy-ledger.md`:
- Line 413: Use one explicit n64-systemtest statement consistently: in
docs/accuracy-ledger.md at lines 413-413, replace ambiguous “impact: none”
wording with “not measured; n64-systemtest has no AI coverage”; in CHANGELOG.md
at lines 28-32, add the same measured/not-measured statement and do not claim a
before/after failing-assertion count.

In `@docs/residuals/R-18.md`:
- Line 29: Mark the earlier contradictory WDC residual investigation text near
the document index as historical or initial investigation state, while
preserving the later corrected status and append-only content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c33d4f33-f4bd-4312-919c-2bc3e259bdc3

📥 Commits

Reviewing files that changed from the base of the PR and between 147e709 and b0770c0.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • docs/accuracy-ledger.md
  • docs/residuals/R-18.md

Comment thread docs/accuracy-ledger.md Outdated
Comment thread docs/residuals/R-18.md
CodeRabbit posted seven inline findings on #205; five adopted, two rejected with
reasons. The two that mattered were real defects in my own tests.

VACUITY (the sharpest finding, adopted). My new
`set_region_before_dacrate_...` asserted `emitted < 5_000` after a SINGLE `tick`
-- but the first tick returns at the `next_sample_tick == 0` anchor branch, so
`emitted` was ALWAYS 0 and the bound passed with the emission path completely
broken. Fixed by priming the clock and making the bound two-sided: only the pair
is evidence, since an upper bound alone accepts a DAC that emits nothing, which
is precisely the state that caused this livelock.

Chasing that found the SAME defect in a PRE-EXISTING test, which the review did
not flag: `idle_tick_emits_nothing_before_dacrate` also called `tick` once, so it
too passed at the anchor branch regardless of the rate -- it passed identically
before and after a change to the exact behavior it claimed to pin. Renamed to
`the_first_tick_only_anchors_the_sample_clock`, re-aimed at the rule it actually
exercises, and given a second half asserting that the NEXT tick does emit,
without which it would pass against a DAC that never emits at all.

All three audio tests are now mutation-checked together: reverting either half of
the fix turns all three red.

STALE CONTRACT (adopted). `sample_rate`, `period_ticks` and `tick` still
documented an unset rate as zero/stopped, which the fix made false. Synchronized
-- and the `period == 0` early return is now labeled as a divide-by-zero guard
rather than a modeled DAC state, which is what it actually is.

docs/audio.md (adopted; a rule I broke). "A chip change touches the chip code AND
its docs/<chip>.md in the same commit." It did not. Added a section covering the
reset model, why it is a CORRECTNESS requirement and not an audio nicety, the
WDC evidence, and the known DACRATE=0 simplification.

PROVENANCE (adopted). The comment asserted "hardware has no stopped-DAC state" as
fact while admitting two paragraphs later that reset semantics are undocumented.
Now split explicitly into ESTABLISHED (ares runs its DAC from power-on),
INFERRED (a divider has no "off" encoding, so hardware likely matches), and NOT
ESTABLISHED (what AI_DACRATE holds at reset).

n64-systemtest wording (adopted): "impact: none" reads as a measurement that came
back clean. It is "not measured, and it cannot be" -- the suite has zero AI
coverage. Stated that way in both the ledger and the CHANGELOG.

R-18 historical marker (adopted): a header now states that everything under
`## Status` is append-only investigation history containing retracted claims, so
a paragraph read in isolation is not mistaken for current status.

REJECTED, with reasons:

- `DEFAULT_DAC_HZ` -> `pub(crate)` "in case a diagnostic tool needs it": nothing
  outside the crate reads it. Widening visibility on speculation is the inert-API
  hazard docs/engineering-lessons.md §3.2 describes.
- Repurposing spare bits in existing serialized state to smuggle in
  `dac_rate_programmed` without changing the layout: worse than the honest
  alternative. It makes the save-state format misdescribe itself, and the
  simplification is already ledgered with the ares line fidelity would require.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
no_std thumbv7em, markdownlint, check_no_roms, check_en_us -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication — 7 CodeRabbit findings + 3 Antigravity items

Five adopted, two rejected with reasons. Two were real defects in my own tests, and one of those led to a third the review didn't flag.

Adopted

lib.rs:724 — "the first tick only anchors, so emitted is always zero and the bound passes even if the emission path is broken". Correct, and the sharpest finding here.

Audio::new() leaves next_sample_tick == 0, so a single tick returns at the anchor branch. My assert!(emitted < 5_000) was vacuous — it would have passed against a DAC that emits nothing, which is precisely the state that caused this livelock. Fixed by priming the clock and making the bound two-sided:

ai.tick(0, &mut bus);              // prime
ai.tick(MASTER_HZ / 60, &mut bus); // one frame
assert!((500..5_000).contains(&emitted));

Only the pair is evidence: the upper bound rejects a video-clock rate, the lower bound rejects silence.

Chasing it found the same defect in a pre-existing test, which this review did not flag. idle_tick_emits_nothing_before_dacrate also called tick once, so it too passed at the anchor branch regardless of the rate — it passed identically before and after a change to the exact behavior it claimed to pin. Renamed to the_first_tick_only_anchors_the_sample_clock, re-aimed at the rule it really exercises, and given a second half asserting the next tick does emit — without which it would still pass against a permanently silent DAC.

All three audio tests are now mutation-checked together:

fix reverted -> full_clears_even_when_dacrate_was_never_programmed          FAILED
                set_region_before_dacrate_does_not_fabricate_the_..._rate  FAILED
                the_first_tick_only_anchors_the_sample_clock               FAILED
restored     -> 20 passed

lib.rs:229docs/audio.md not updated. Adopted; this is a rule I broke. "A chip change touches the chip code AND its docs/<chip>.md in the same commit." Added a section covering the reset model, why it is a correctness requirement rather than an audio nicety (retirement lives inside the DAC's sample step, so a stopped DAC latches FULL), the WDC evidence, and the DACRATE = 0 simplification.

lib.rs:248 — stale unset-rate contract. Adopted. sample_rate, period_ticks and tick all still documented an unset rate as zero/stopped. Synchronized — and the period == 0 early return is now labeled as the divide-by-zero guard it actually is, rather than a modeled DAC state.

lib.rs:447 — don't present the fallback premise as established hardware behavior. Adopted, and a fair hit. The comment asserted "hardware has no stopped-DAC state" as fact while admitting two paragraphs later that reset semantics are undocumented. Now split explicitly:

  • ESTABLISHED — ares runs its DAC from power-on (AI::power() sets 44100; AI::main() calls sample() unconditionally).
  • INFERRED — a divider has no "off" encoding, so the hardware counter likely matches.
  • NOT ESTABLISHED — what AI_DACRATE holds at reset. Hence a modeling default.

accuracy-ledger.md:413 — one consistent n64-systemtest statement. Adopted. "impact: none" reads as a measurement that came back clean. It is "not measured, and it cannot be" — the suite has zero AI coverage — now stated that way in both the ledger and the CHANGELOG.

R-18.md:29 — mark superseded material as historical. Adopted. A header now states that everything under ## Status is append-only investigation history containing retracted claims (the black-rasterization claim, lit-pixels-as-evidence, VI_CTRL staying 0, and the WDC frame), so a paragraph read in isolation isn't mistaken for current status.

Rejected, with reasons

lib.rs:451 — keep a literal AI_DACRATE = 0 distinct from reset. The analysis is right and ares confirms it (max(1, video_clock / (dacRate + 1)) honors a literal zero). Rejected as a code change in this PR: separating them needs a dac_rate_programmed field in a serialized struct, which changes the save-state layout (ADR 0005) — an announced-in-advance change in this repo, not one to attach to an unrelated livelock fix. It is unobservable in practice (no title requests a ~48 MHz DAC) and is now ledgered in R-16 and docs/audio.md with the exact ares line fidelity would require. You also asked for "an explicit save-state compatibility plan if serialised state must change" — agreed, and that plan is the reason this isn't done here rather than a reason to do it quietly.

Antigravity: repurpose spare bits in existing serialized state to avoid the layout change. Rejected, and this one I'd push back on firmly: smuggling a new field into unused bits makes the save-state format misdescribe itself. An honest layout change announced in advance is strictly better than a hidden one that no future reader can discover from the struct.

Antigravity: make DEFAULT_DAC_HZ pub(crate) in case a diagnostic tool needs it. Nothing outside the crate reads it. Widening visibility on speculation is the inert-API hazard docs/engineering-lessons.md §3.2 describes — and this repo has already deleted a stale translate and four dead cvt_* converters for exactly that reason.

Antigravity nitpick: the CHANGELOG entry is verbose. Partially adopted — trimmed, with the detail pointing at R-16 and docs/audio.md. What stays is the retraction and the "does not make WDC render" clause: a user reading the changelog should not come away thinking a title started working.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/residuals/R-18.md (1)

34-37: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use MD060’s padded table separator.

Line 35 must use | --- | --- |; |---|---| violates the repository’s configured padded table style.

Proposed fix
-|---|---|
+| --- | --- |

Based on learnings, the pinned hook does not enforce configured MD060 padding, so this requires manual review.

🤖 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/residuals/R-18.md` around lines 34 - 37, Update the Markdown table
separator in the residuals document to use padded cells, changing the separator
associated with the “Item” and “State” columns to the repository-required `| ---
| --- |` style while leaving the table content unchanged.

Sources: Path instructions, Learnings

🤖 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.

Outside diff comments:
In `@docs/residuals/R-18.md`:
- Around line 34-37: Update the Markdown table separator in the residuals
document to use padded cells, changing the separator associated with the “Item”
and “State” columns to the repository-required `| --- | --- |` style while
leaving the table content unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 06ff1410-4008-4bae-9d2d-8332f218265f

📥 Commits

Reviewing files that changed from the base of the PR and between b0770c0 and 58ff847.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • crates/rustyn64-audio/src/lib.rs
  • docs/accuracy-ledger.md
  • docs/audio.md
  • docs/residuals/R-18.md

…literal

Adopts all three items from the second Antigravity review on #205.

1. `new()` duplicated `recompute_rate`'s `dac_rate == 0` decision as a literal,
   kept correct only by a comment saying "must match `recompute_rate`" -- exactly
   the comment-enforced invariant this project distrusts, and the drift would be
   silent: a constructor left at 0 puts the machine back in the stopped-DAC state
   R-16's livelock needs, and no test of a *programmed* DAC would notice. `new()`
   now calls `recompute_rate()` (both are `const fn`, so this stays const).

   This measurably improves the tests: a SINGLE-POINT mutation of
   `recompute_rate` alone now fails four tests, where before the same defect
   needed both halves reverted before anything went red.

2. Added `TODO(T-AUDIO-01)` so the deferred AI_DACRATE=0 separation is
   discoverable from the code, not only from ledger R-16 -- it needs a
   save-state layout bump (ADR 0005), which is why it is deferred.

3. Added `debug_assert!(period > 0)` beside the `period == 0` guard. The comment
   claimed the state is unreachable on a constructed machine; the assert makes
   that claim CHECKABLE rather than merely stated, and a future change that
   reintroduces a zero rate is precisely the R-16 defect -- which hid the first
   time behind exactly this silent `return`.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
markdownlint, check_en_us -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication — second Antigravity review: all three adopted

1. new() duplicated the fallback decision. Adopted, and it improved the tests measurably.

You're right, and the duplicate was worse than redundant: it was kept correct only by a comment reading "must match recompute_rate" — exactly the comment-enforced invariant this repo distrusts on principle. The drift would also have been silent: a constructor left at 0 puts the machine back in the stopped-DAC state R-16's livelock needs, and no test of a programmed DAC would notice.

new() now calls recompute_rate(); both are const fn, so it stays const.

The concrete gain is in mutation sensitivity:

before: mutate recompute_rate alone -> tests still pass (needed BOTH halves reverted)
after:  mutate recompute_rate alone -> 4 tests FAIL

One place to get wrong instead of two, and the suite now catches a single-point defect.

2. Add an explicit TODO for the deferred save-state bump. Adopted. TODO(T-AUDIO-01) beside the branch, so the deferral is discoverable from the code rather than only from ledger R-16.

3. debug_assert!(period > 0). Adopted, and it is the right instinct here. The comment claimed the state is unreachable on a constructed machine; the assert makes that claim checkable instead of merely asserted. It also guards the exact defect this PR fixes — and note that a silent return on a zero period is precisely how R-16 hid the first time, so turning that path into a debug-build failure is more than hygiene.


That closes every finding from both bots across three review rounds: 7 CodeRabbit + 6 Antigravity, 11 adopted, 3 rejected with reasons (pub(crate) on speculation, repurposing spare save-state bits, and the AI_DACRATE = 0 split as a code change here rather than a ledgered deferral).

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR changes the default unprogrammed AI_DACRATE in Audio from 0 Hz to 44.1 kHz, preventing AI_STATUS.FULL from latching indefinitely when games poll audio DMA status prior to setting a sample rate.

Blocking issues

None found.

Suggestions

  • crates/rustyn64-audio/src/lib.rs:437: recompute_rate() branches on self.dac_rate == 0 to detect the unprogrammed state. This conflates an uninitialized state with an explicit software write of 0 to AI_DACRATE. When a save-state layout bump is permissible, add a dac_rate_programmed: bool flag to distinguish unprogrammed reset from an explicit zero write.
  • crates/rustyn64-audio/src/lib.rs:508: debug_assert!(period > 0) is placed inside the if period == 0 block. In release builds, debug_assert! is compiled out, leaving a silent return, whereas in debug builds it panics. If a zero period is structurally impossible on a constructed machine, move debug_assert!(period > 0) before the check or use unreachable!("constructed AI must never have a stopped DAC") inside the guard.

Nitpicks

  • CHANGELOG.md:9-45: The changelog entry contains extensive root-cause investigation notes and measurement logs. Keeping changelog entries focused on high-level impact while reserving investigation details for docs/ and the commit body would keep CHANGELOG.md cleaner.

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

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.

1 participant