Skip to content

Play queued buffers in order across the buffer id wrap - #1

Merged
Cellenseres merged 1 commit into
Cellenseres:mainfrom
oneup03:fix-buffer-id-wrap
Nov 1, 2026
Merged

Cellenseres merged 1 commit into
Cellenseres:mainfrom
oneup03:fix-buffer-id-wrap

Conversation

@oneup03

@oneup03 oneup03 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Problem

libctru numbers each channel's wave buffers 1 to 65535 and then starts again from 1 (ndspChnWaveBufAdd, which skips 0). dequeue() in firmware/voice.c plays the lowest id in the queue next, so it breaks at the wrap:

  1. Just before the wrap, the voice holds, for example, current = 65532 and queue = [65533, 65534, 65535, 1].
  2. When 65532 ends, dequeue() picks 1, the lowest id, ahead of 65533–65535.
  3. In ndspiReadChnState, libctru walks its list up to the DSP's current id and marks 65533–65535 as done. On the DSP they stay queued, and because every later id is lower, they never play.
  4. With three of the five slots taken for good, voice_queue() returns -1 for some of libctru's adds. libctru still counts those buffers as sent and marks them done once the voice moves past them, so they never play.

A channel that is fed without pause and never reset hits this after 65,535 buffers. From then on it uses up its stream faster than real time.

Where it showed up

rt64-3ds, the 3DS renderer and platform layer for several N64 recompilation ports (Goemon 64, Banjo-Kazooie, Donkey Kong 64, Dinosaur Planet), embeds OpenDSP 1.2.2. Each game's audio stream is one ndsp channel, fed with 60 buffers a second. On an Old 3DS log from Goemon 64:

  • About 18 minutes in, which is about 65,500 buffers, the stream's consumption jumped from 44,095 to about 55,000 frames a second and stayed there.
  • The queue's low point fell from about 2,590 frames to about 1,160.
  • The game paces its music to that stream, so the music ran about a quarter fast for the rest of the session.

A simulation of libctru's bookkeeping against this dequeue() reproduces both changes. With buffers played in queue order instead, nothing changes at the wrap.

Fix

The ids are compared as a signed 16-bit distance, (s16)(a - b) < 0. Queued ids are always within a few of each other, so this gives the same order as before except across the wrap, where 1 now plays after 65535.

Testing

  • A host-side check of the comparison: the wrap cases (65535 vs 1, 65533 vs 2) and ordinary ids order correctly.
  • Not done: I couldn't rebuild firmware/opendsp.cdc, since I don't have the teak-llvm toolchain built, so this PR changes the source only. The image needs rebuilding, and the change hasn't run on hardware.
  • Meanwhile rt64-3ds works around it on the host. Before a channel's ids wrap, it clears the channel with ndspChnWaveBufClear, which resets libctru's counter; the next buffer then carries the partial reset that empties the DSP's queue. It then queues the unplayed buffers again.

🤖 Generated with Claude Code

libctru numbers a channel's wave buffers 1 to 65535 and then from 1
again. dequeue() played the lowest id next, so at the wrap the last
buffers numbered before it stayed in the queue for good while the new,
lower ids played ahead of them. With those slots taken, later adds
beyond the free slots were dropped while libctru counted them played:
a channel fed without pause (a game's output stream, 60 buffers a
second) ran about a quarter fast from 18 minutes in. The ids are now
compared as a signed 16-bit distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oneup03 added a commit to oneup03/rt64-3ds that referenced this pull request Nov 1, 2026
libctru numbers a channel's buffers 1 to 65535 and then from 1 again,
and OpenDSP 1.2.2 plays the lowest number it holds next. At the wrap the
last buffers numbered before it stayed in its five-slot queue for good,
later buffers beyond the slots left were dropped while libctru counted
them played, and the AI stream (and a game's music, which keeps pace
with it) ran about a quarter fast from 18 minutes in: Goemon on an Old
3DS went from 44,095 frames a second to ~55,000, low water 2,590 to
~1,160, after ~65,500 buffers.

With OpenDSP running, once the last buffer's number reaches 0xFF00 the
channel is cleared (libctru numbers from 1 again, and the next buffer
carries the partial reset that empties OpenDSP's queue) and what had yet
to play is queued again from where the DSP is. audio_frames_played holds
its count across it, so the DSP synthesizer's clock does not jump. The
synthesizer's voices reset their channels at every start and never get
near the wrap. Fixed upstream in Cellenseres/OpenDSP#1 (not yet in a
release; the firmware image is embedded prebuilt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Cellenseres
Cellenseres merged commit 287a56e into Cellenseres:main Nov 1, 2026
2 of 3 checks passed
oneup03 added a commit to oneup03/rt64-3ds that referenced this pull request Nov 1, 2026
…d dropped

The submodule moves from v1.2.2 to upstream main a15c861 (no release yet):
Cellenseres/OpenDSP#1, the queue's oldest buffer played first across
libctru's u16 buffer-id wrap, merged, and the firmware image rebuilt with
it in "Fix audio resume", which also keeps the DSP's state across sleep
and HOME (a 144 KB linear buffer while OpenDSP runs). opendsp-patches/0001
still applies.

renumber() (eea1550) goes. Each wrap of the AI stream's buffer ids (about
every 18 minutes) is logged instead, so a log shows whether the stream's
rate held across it.

firmware.s's .incbin images are OBJECT_DEPENDS of it: the assembler's
dependency output leaves them out, and this bump otherwise linked the old
firmware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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