Skip to content

Fix off-by-one OOB table read in Sample::next()#331

Merged
tomcombriat merged 1 commit into
sensorium:masterfrom
94xhn:fix-sample-oob-endpos
Jul 19, 2026
Merged

Fix off-by-one OOB table read in Sample::next()#331
tomcombriat merged 1 commit into
sensorium:masterfrom
94xhn:fix-sample-oob-endpos

Conversation

@94xhn

@94xhn 94xhn commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Sample::next() uses a strict > comparison to decide when playback has
reached the end of the table and should wrap (looping) or stop:

if (phase_fractional>endpos_fractional){

isPlaying() already treats phase_fractional == endpos_fractional as
"finished" (it uses phase_fractional<endpos_fractional to mean "still
playing"), so the two are inconsistent at the exact boundary.

endpos_fractional is set to NUM_TABLE_CELLS << SAMPLE_F_BITS (i.e. exactly
one past the last valid table index, in fixed-point). Because
phase_increment_fractional is derived from an integer frequency step, for a
lot of common frequency/table-size combinations phase_fractional lands
exactly on endpos_fractional after some number of calls (not just "goes
past" it). When that happens, next() still takes the else branch this
call, computes:

unsigned int index = phase_fractional >> SAMPLE_F_BITS; // == NUM_TABLE_CELLS
out = FLASH_OR_RAM_READ<const int8_t>(table + index);

which reads one element past the end of table (and, under
INTERP_LINEAR, also reads table[index+1], two past the end) before the
wrap/stop logic finally kicks in on the following call.

I confirmed this with a standalone harness (compiling next() /
incrementPhase() copied verbatim from this file against a real buffer with
a canary byte placed one past the array): with a 100-cell table clocked to
land exactly on endpos_fractional, the existing code reads the canary byte
(confirmed out-of-bounds access) once per loop cycle; under INTERP_LINEAR
it reads two canary bytes past the end. examples/08.Samples/Sample_Loop_Points
exercises exactly this full-range-plus-looping configuration against a
table (ABOMB_DATA, 16384 cells, no padding) where the OOB read would land
outside the array.

Changing the comparison to >= makes next() treat phase == endpos as
finished/wrap, matching isPlaying(), removes the OOB read, and does not
change behavior for the (much more common) case where phase never lands
exactly on endpos> and >= are equivalent whenever equality never
occurs.

Minimal one-character fix, no behavior change other than removing the OOB
read.

Sample::next() used a strict > comparison (phase_fractional >
endpos_fractional) to decide when to wrap/finish playback, while
isPlaying() already treats phase_fractional == endpos_fractional as
finished (using <). When phase lands exactly on endpos_fractional
(which happens deterministically for many freq/table-size ratios),
next() would compute index = endpos_fractional >> SAMPLE_F_BITS ==
NUM_TABLE_CELLS, one past the last valid table entry, and read (and
for INTERP_LINEAR, also read index+1) out of bounds before wrapping
on the following call.

Change the comparison to >= so next() treats phase == endpos as
finished/wrap, consistent with isPlaying(), eliminating the
out-of-bounds table read.
@tomcombriat

Copy link
Copy Markdown
Collaborator

Looks good indeed, thanks for spotting that and for the detailed explanation of the root of the problem.

Merging.

@tomcombriat
tomcombriat merged commit 0643a6f into sensorium:master Jul 19, 2026
12 of 13 checks passed
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