These turned up during the interpreter performance work on development. They were found by testing on the boards and by read-only code surveys. Each one below is present on main. For each, the report gives where it is, what goes wrong, how to confirm it, and a suggested fix.
development and have since been ported to main.Line numbers for items 1-20 are main at cbd99fb (2026-09-25); for items 21-27, main at 1adeb0e. "Confirmed" means seen on a board; "by reading" means found in the code and not yet reproduced.
| # | Area | Problem | Evidence | Severity | Status |
|---|---|---|---|---|---|
| 1 | PIO interrupts | TX "was full" test reads SM0's SHIFTCTRL for every state machine | by reading | medium | fixed on main b7aad97 |
| 2 | I2C slave | clearing a slave-ready bit can wipe one the ISR sets at the same moment | by reading | medium | fixed on main 0227df6 |
| 3 | WiFi | WEB SCAN without a connection never polls, so it finds nothing | confirmed | medium | fixed on main bd1a965 |
| 4 | DO loops | the trace cache's DO fast path truncates a fractional limit | by reading | medium | fixed on main 4c42f0c |
| 5 | STATIC | a STATIC used after a nested call can attach to the wrong variable | confirmed | high | fixed on main b0f448e |
| 6 | OPTION LOCAL VARIABLES | a value above the compile-time size overflows g_hashlist | confirmed (hard fault) | medium | fixed on main d2a8fa9 |
| 7 | Name tables | three probe loops can hang when their table is full | by reading | high | part 1 fixed on main 1c95cd1; part 2 not reachable in practice |
| 8 | Labels (RP2350) | GOTO to a label named like a SUB uses a SUB index as a pointer | confirmed | medium | fixed on main 2d5307e |
| 9 | FindSubFun | the type argument is ignored on RP2350 but used on RP2040 | confirmed | low | fixed on main edc7999 |
| 10 | CALL, tokenise | no length checks on two 256-byte buffers | confirmed (tokenise) | medium | tokenise fixed on main c4ea0e2; the rest left, see the card |
| 11 | Interrupts | the IRETURN stub rti[2] has no terminator | by reading; latent | low | fixed on main fc1ca32 |
| 12 | CountLines | p[0] where p[1] is meant, on long lines | by reading; no visible effect | low | fixed on main 1a999a3 |
| 13 | OPTION PROFILING | interrupt SUBs and ON…GOSUB unbalance the per-SUB frames | confirmed | low | fixed on main 7238252 |
| 14 | MM.INFO(VARCNT) | stale after ERASE and after locals are freed | confirmed | low | fixed on main aa98747 |
| 15 | CSUB header | int dims[] selected only for PICORP2350 | checked by reading | low? | not a firmware bug; header comment and manuals corrected on main 63dea76 |
| 16 | ON ERROR SKIP | a nested call inside an argument can leak the outer call's frame | confirmed, both boards | low | heap leak fixed on main 52078f6; nested case left |
| 17 | Trace cache | global slot indices are never invalidated | by reading | low | gone: trace cache removed on main c50b6bc |
| 18 | Stepper | RUN during a job wipes the heap under the ISR | confirmed | high | fixed on main db99419 |
| 19 | Stepper | STEPPER ESTOP leaks the buffer of the arc that is executing | confirmed | low | fixed on main 2794fe3 |
| 20 | Stepper | arcs ran at about 2.4 mm/s with F6000 requested | confirmed | medium | deferred (Peter, 26 September) |
| 21 | Program flow | a FOR, DO or GOSUB line ending in : ' comment leaves the loop, or returns past the next line | confirmed, both boards | high | fixed on main e49d3ff (from development 8740953) |
| 22 | MAX, MIN | results clamp at the single-precision limit, ±3.4e38 | confirmed | low | fixed on main 50c9aa0 |
| 23 | ABS, SGN | a float of 263 or more raises "Number too large" | confirmed | low | fixed on main 16d0413 |
| 24 | Arrays | integer subscripts are cut to 32 bits before the bounds check: a(4294967297) reads a(1) | confirmed | medium | fixed on main 8f617d8 |
| 25 | Arithmetic | only +infinity from + and * is trapped; subtraction and -infinity never are | confirmed | low | -INF/NaN printing fixed on main a40112b (option A) |
| 26 | Conversions | NaN becomes 9223372036854775807, printed and assigned without an error | confirmed | low | fixed on main a40112b (option A) |
| 27 | Operator ^ | an integer power runs one multiplication per unit of the exponent, with no Ctrl-C | confirmed (timing) | low | fixed on main c04dc39 |
| 28 | RANDOMIZE (RP2040) | a bare RANDOMIZE raises "Number out of bounds" once the board has been up about 36 minutes | confirmed | medium | fixed on main 1457087 |
| 29 | Program flow | calling a one-letter SUB alone on a line skips the next line | confirmed | high | fixed on main 9c1aeb9 |
| 30 | Local variables | markers left by freed locals were never cleared; after a deep recursion a new local failed with "Too many local variables" | confirmed | medium | fixed on main 8127da0 |
Items 1-4 are done. Suggested order for the rest: 5, 7 and 18.
Where: core/MM_Misc.c:10265, checkdetailinterrupts: int full = (pioinuse->sm->shiftctrl & (1 << 30)) ? 8 : 4;
pioinuse->sm is the array, so this always reads state machine 0's SHIFTCTRL. For SMs 1-3 the "TX FIFO was full" depth depends on whether SM0's FIFO is joined, not their own. A PIO TX interrupt on SM 1-3 then fires on the wrong edge or never fires.pioinuse->sm[sm].shiftctrl, as on development in 791bf93.Where: the clears are at core/MM_Misc.c:10410, 10416, 10422, 10428 (I2C_Status &= ~…). The ISRs set bits at io/I2C.c:391, 396 (and 407, 412 for I2C2).
&= is a load, a modify and a store. If the I2C interrupt sets the other ready bit between the load and the store, the store wipes it. A receive and a send request arriving together can lose one interrupt.save_and_disable_interrupts/restore_interrupts), as ClearI2CStatus() does on development in 791bf93.Where: net/WiFi.c:304: if (!WIFIconnected && startupcomplete) goto flashonly; in ProcessWeb, and WEB SCAN's wait loop at net/WiFi.c:575.
cyw43_arch_poll(). Without a connection ProcessWeb skips straight to the heartbeat, so nothing polls. The only results that get through are ones processed during the next scan's start-up command.WebScanActive on development) set for the scan's wait. ProcessWeb polls whenever it is set, even with no connection (development a07e8f2).Where: core/MMtrace.c:3497, dofast_fill: *out_limit_i = (ctype == T_INT) ? limi : (int64_t)limf;. This path is always on in the eleven trace-cache builds, even with the trace cache switched off.
DO : INC i% : LOOP WHILE i% < 2.5 stops at 2; the evaluator (and every other build) runs to 3.Bas/bench/inttests/dotest.bas checks 16 condition forms against the evaluator. On main the smallest fix is to keep a float limit when the number is not an integer.Where: core/MMBasic.c:2629 sets CurrentSubFunName on every SUB/FUNCTION entry and nothing restores it. STATIC builds its backing variable's name from it at core/Commands.c:8778-8788 (SUBNAME + "\x1e" + VAR).
SUB A, a STATIC executed after a call to SUB B is mangled as B\x1eVAR. It can find or create B's static instead of its own, sharing or losing state. CurrentInterruptName also overrides the SUB name for every STATIC run inside an interrupt SUB, including in SUBs that the interrupt SUB calls.SUB A : B : STATIC n : INC n : PRINT n : END SUB with a SUB B that has a STATIC n of its own; call A several times and watch n.CurrentSubFunName in the frame and restore it on return. Alternatively, derive the name from subfun[index] of the current frame when the STATIC executes.g_hashlistWhere: core/MMBasic.c:225 struct s_hash g_hashlist[MAXLOCALVARS]. maxlocalvars can be raised up to MAXVARS-32 at :6481, and each local is pushed with no bound at :5253.
g_hashlist[g_hashlistpointer++] writes past the array into whatever follows in BSS.g_hashlist to MAXVARS, or cap OPTION LOCAL VARIABLES at MAXLOCALVARS, or bound the push and raise "Too many local variables".probe_global_slot (core/MMBasic.c:4556) only checks for wrap-around when called from inside a SUB. At level 0 a global region full of live entries and tombstones loops forever.hashlabels' guard if (originalhash < 0) (:3713) tests an unsigned value, so it is never true. A full funtbl loops forever at PrepareProgram.FindSubFun on the RP2350 probes funtbl with no bound.Where: SUB/FUNCTION names and labels share funtbl. For a label, index is a line pointer; for a SUB it is a small subfun index. The RP2350 findlabel (core/MMBasic.c:3742) does not check which kind it found.
index >= MAXSUBFUN in findlabel, as FindSubFun accepts only < MAXSUBFUN. Either reject a label that duplicates a SUB name at PrepareProgram, or allow both and keep them apart.Where: the RP2350 FindSubFun at core/MMBasic.c:1966 never reads type. The RP2040 version at :2020 filters SUB/CSUB (0) from FUNCTION (1) and checks the suffix.
() inside an expression is found as a function on the RP2350 but not on the RP2040, so the same program behaves differently on the two chips.tokenise never checked its output position in tknbuf (256 bytes), and output can be longer than input: the 2-byte line header, the line number, an inserted LET or PRINT, and the CALC token with MM.ANSWER on every prompt statement that starts with an operator. Confirmed 26 September: a program line of 62 short assignments (about 374 bytes tokenised) restarted the RP2040 VGA board, and +1: typed 80 times at the prompt (about 1,190 bytes) hung it.tknbuf stays at 256 bytes. Every write to it is checked, and a line that will not fit is refused with "Line is too long". While a program is being saved, tokenise returns 1 instead, so SaveProgramToFlash, SaveProgramToRAM and the library save close their write first (SaveProgramToFlash has interrupts off until then). A 204-character line that tokenises to 242 bytes still runs. Test: Bas/bench/tknbuf10.py.tknbuf (26 September):
strcat the argument text onto the name in a 256-byte temporary string.? to "Print " and add a space after each command name, so a listed line can reach about 900 characters from a 255-character source. The callers' buffers, replaceAlpha (256 bytes) and str_replace (288 bytes) all hold it. LIST of a line of ?n: statements hard-faulted the PC3. With OPTION CONTINUATION LINES, the " _" breaks format_string adds can overrun LIST's 256-byte buffer on an ordinary 255-character line.MIN(, MAX(, LEFT$(, HEX$( and similar names are expanded as text (MIN( becomes TOPBOTTOM(I,) through str_replace's 288-byte stack buffer. A 241-character line of nested MIN( at the prompt hard-faulted the PC3 (PC=42504F54, the text "TOPB").tknbuf with strcpy, which stops at the first statement, so a long EVAL followed by another statement gives "Invalid character"; EXECUTE drops the rest of the line. Both confirmed on the PC3. Programs are not affected.Where: core/MM_Misc.c:10213 static char rti[2] holds only the 2-byte IRETURN token. The END SUB of an interrupt SUB returns to it, and skipelement/checkend read the byte after it.
rti is linker fill and always zero. A 1-byte variable whose name sorted just after rti would have broken every interrupt SUB's return with "Unexpected text".static char rti[4]; the zeros are now part of the array. It takes the two bytes of fill, so RAM use is unchanged.Where: core/MMBasic.c:3953 p += p[0] + 2;. This is reached only for lines whose skip byte is 0xFE (length unknown). The label length is in p[1].
p += p[1] + 2;, as every other label skip in the source already does.Where: a SUB-style interrupt (GotAnInterrupt, core/MM_Misc.c:10527) and ON…GOSUB (core/Commands.c:8625) raise g_LocalIndex without EnterLocalFrame. Their END SUB or RETURN goes through cmd_return, which calls LeaveLocalFrame (:5976).
g_LocalIndex stays balanced.EnterLocalFrame. An interrupt SUB is also counted and timed under its own name, since the dispatch that counts SUB calls never sees it. Now 1,000,061 µs, 500,065 µs, and the tick SUB with 19 calls. Test: Bas/bench/prof13.py.Where: g_varcnt is updated only when a variable is created. erase() (core/MMBasic.c:6577) and ClearVars(level) decrement g_Globalvarcnt/g_Localvarcnt but not the total. MM.INFO(VARCNT) is at core/MM_Misc.c:9358.
g_Globalvarcnt + g_Localvarcnt. Test: Bas/bench/varcnt14.py, passes on the PC3 and the VGA.int dims[] only for PICORP2350 (to verify)Where: PicoCFunctions.h:400 selects int dims[MAXDIM] only #ifdef PICORP2350, else short. Every RP2350 firmware build uses int dimtbl[5], and the variable-table layout is part of the CSUB ABI (CallTable 0x60).
PICORP2350 is not defined for them, a CSUB that walks g_vartbl reads the wrong offsets.PICORP2350 is the CSUB author's chip select, not the firmware variant: with it defined the header gives MAXDIM 5 and int dims[], as every RP2350 build has (rp2350 comes from IS_RP2350), and without it 6 and short, as every RP2040 build has. The other fields line up byte for byte.g_vartbl (dims[]: 5 × int on the RP2350, 6 × short on the RP2040) nor Option (laid out per variant). armcfgen §1.5 now says so, with how to build one that does; mmb2csub notes that its generated code reads neither, so its claim stands. The user manual makes no such claim.Where: DefinedSubFunMem (core/MMBasic.c:536) is one global sentinel. It is set during argument processing (:2387) and cleared on success (:2628). In Foo(Bar(x), bad) the inner call clears it. If the outer call's later argument then errors under OPTION ERROR SKIP, the error handler does not unwind the outer frame.
gosubstack holds every SUB and FUNCTION call, not only GOSUBs. And the error cleanup undid the call's frame but freed nothing, so even without a nested call, every skipped error in a call's arguments leaked: on the RP2040 the 2.8 KB argument block (50 errors in Foo(1/z, 1) used up the VGA's heap, 100,608 bytes, after which each call leaked its frame as well), and on both chips the 256-byte copy of each string argument (13,312 bytes over 50 on the PC3).Bas/bench/argleak16.py, heap unchanged on both boards.Foo(Bar(1), 1/z) - Bar's success clears the one sentinel - and Foo(Bar(1/z)) - the cleanup undoes one level - still leak the outer call's frame (and on the RP2040 its argument block). After 50 such skipped errors no SUB or FUNCTION can be called ("Too many nested SUB/FUN"; seen on the PC3 with a 45-deep call). The full fix would keep a per-call record on the C stack (about 25 lines, no RAM).Where: core/MMtrace.c. Its LET/INC ops store global slot indices and raw pointers. TraceCacheInvalidateAll (:3287) is called only by the trace cache's own option commands, although the file's comments say DIM, ERASE, REDIM and CLEAR invalidate it.
Testfiles/FishFeeder.bas) freed the stale buffer pointer. The Stepper Reference says nothing about RUN or END.InitHeap calls stepper_close_subsystem() before the wipe (Peter chose this over stopping it on RUN/NEW/LOAD only), so every heap wipe - RUN, NEW, LOAD, CHAIN, and FILES / EDIT FILE / RAM FILE LOAD from inside a program - ends a stepper job and a program must STEPPER INIT again. Test: Bas/bench/steprun18.py.do_end aborts the stepper only after an error, so a queued job carries on after CTRL-C.stepper_release_current_arc(). A 157 mm arc stopped after 1 s lost 3,584 bytes until the next RUN; now 0. Test: Bas/bench/estoparc19.py (PC3 with audio disabled, X on GP2/GP3, Y on GP4/GP5).These turned up while designing Route B on development, from four code surveys and board checks on the PC3 and the RP2040 VGA. Line numbers are for main at 1adeb0e. "Confirmed" means seen on a board running a development build whose code in that area is the same as main's.
: ' comment runs onceWhere: the comment fast path in ExecuteProgram, core/MMBasic.c:1871. It jumps to the line after line_start, the last T_NEWLINE the loop crossed. NEXT, LOOP, RETURN and IRETURN jump back into the middle of an earlier line without crossing its T_NEWLINE, so a comment element they land on is taken to belong to the wrong line.
FOR i = 1 TO 3 : ' c runs its body once.DO : ' d … LOOP UNTIL k = 2 leaves after one pass.GOSUB x : ' c returns to the line after the RETURN's line.FOR i = 1 TO 3 ' c) the comment is part of the FOR statement, and nothing goes wrong. The code is in the repository's first commit, so every release since 6.03.00 has it.Bas/bench/cmtloop.py PORT fails on both boards before the fix and passes after it.p > line_start && p < line_start + line_skip (development 8740953; on main e49d3ff). The same shortcut in GetNextCommand (:7194) is safe, because its line_start is local to one forward scan.Where: fun_max_min, core/Functions.c:1855-1890, starts from -FLT_MAX or FLT_MAX.
MAX(-1e300, -2e300) gives -3.402823466e+38, and MIN(1e300, 2e300) gives 3.402823466e+38. Fix: start from the first argument, or from -DBL_MAX/DBL_MAX.Bas/bench/maxsub22.py (PC3 and PICOMIN).Where: fun_abs and fun_sgn (core/Functions.c:555, 1232) evaluate their argument with an integer target type, so a float argument goes through FloatToInt64.
ABS(1e19) raises "Number too large". Fix: evaluate with T_NOTYPE and branch on the type that comes back.ABS(-1e19), SGN(1e300)), while ABS(-1.5) and ABS(1e18) work. evaluate with an integer target always runs FloatToInt64 on a float result but still returns the float, which ABS and SGN then use, so the conversion's only effect is the error. No other function does this. Fix: evaluate with T_NOTYPE (two words). Awaiting Peter.Bas/bench/numedge23.py, PC3 and VGA.Where: the subscript loops in findvar (core/MMBasic.c:4834) and the struct-member paths (:4220, 4344). A float subscript goes through FloatToInt32, but an integer one is stored with dim[i] = in, which truncates the 64-bit value.
DIM a(3), a(4294967297) reads a(1) without an error. A runaway index in a user's program can therefore hit the wrong element silently. Fix: range-check in against INT_MIN/INT_MAX before the assignment and raise "Index out of bounds".a(2^31) wrapped negative and said "Dimensions"). Test: Bas/bench/maxsub22.py.Where: core/Operators.c. op_add, op_mul, op_div and op_exp test fret == INFINITY; op_subtract has no test, and nothing tests -INFINITY.
1e308 + 1e308 raises "Overflow", but 1e308 - (-1e308) prints INF (both seen on the PC3). By reading, no operation traps -INF. Fix: test isinf(fret) in all five, if raising an error is the intended behaviour.EXP(1000) prints INF). Printing -infinity is broken: FloatToStr special-cases only +INF, so for -INF it takes (int) of an infinite exponent, divides -inf by inf and recurses with NaN: garbage on the RP2350 (9223372036854775807e+2147483647) and a hang on the RP2040 VGA (?-1e308-1e308 needed a hardware reset). Options put to Peter: (A) make printing and FloatToInt64 safe for -INF and NaN; (B) also trap isinf() in all five operators. Awaiting Peter.FloatToStr prints -INF and NAN. Arithmetic still produces INF and NaN (option B, trapping in the operators, not taken).Where: FloatToInt64 (core/MMBasic.c:6789). Its range test is false for NaN, so NaN reaches the C conversion. Exactly 263 also passes the test, because the bound rounds to 263, and then converts with undefined behaviour.
x infinite (item 25), y = x - x prints 9223372036854775807, and z% = y stores it. Fix: reject NaN, and use >= 9223372036854775808.0 as the upper bound.INF-INF prints as 9223372036854775807, and INT, FIX, CINT and assignment to an integer all give 9223372036854775807 without an error (PC3). Goes with item 25 option A. Awaiting Peter.Bas/bench/numedge23.py.Where: op_exp's integer branch: for (iret = i = 1; i <= iarg2; i++) iret *= iarg1.
1 ^ 30000000 took 800 ms on the PC3, so 1 ^ 4000000000 would lock the board for about two minutes. Ctrl-C cannot stop it, because the loop never returns to the interpreter. Fix: exponentiation by squaring, which is at most 64 steps and gives the same wrapped result.1^30000000 takes 1.04 s on the PC3 and 1^300000000 10.4 s; a CTRL-C after 1 s was acted on only when the loop ended. Fix: exponentiation by squaring (about 8 lines, same wrapped results, faster above an exponent of about 4). Awaiting Peter.1^4000000000 under 100 ms.Where: cmd_randomize, core/Commands.c, the #ifndef rp2350 version: i = time_us_32(); into an int, then if (i < 0) StandardError(21);.
RANDOMIZE reports "Number out of bounds". It works again for the next 35.8 minutes after the counter wraps at 71.6 minutes, and so on. Found by the goldens corpus (mathr.bas, line 58) on the RP2040 VGA on 26 September, and reproduced at the prompt. The RP2350 has its own RANDOMIZE and is not affected. Fix: take the time seed as time_us_32() & 0x7FFFFFFF, so only a user-supplied negative seed is refused.0x7FFFFFFF; a negative seed the user gives is still refused. Checked on the PICOMIN.Where: the pre-scan in ExecuteProgram, core/MMBasic.c:1873: nextstmt = cmdline = p + sizeof(CommandToken); runs for every statement that is not a comment, including a call to a user SUB, which starts with the name rather than a 2-byte command token.
skipelement runs to the end of the next line. DefinedSubFun pushes that as the return address, and the line after the call is skipped silently.SUB Q ... Q followed by PRINT "next line" never prints.Q 1, Q : PRINT ..., and CALL "Q".sizeof(CommandToken) only when p[0] and p[1] are both command-token bytes; for a SUB call start at p.Where: ClearVars(level), core/MMBasic.c. A returning frame's locals are freed newest first; a freed slot whose next slot is in use becomes a marker (T_BLOCKED), so that a search for a variable further along the run does not stop early. Nothing cleared a marker once it was no longer needed.
ClearVars(0) has wiped every variable, although most of the region is free. Found on 26 September while testing item 6: with OPTION LOCAL VARIABLES 400, a recursion holding 350 live locals failed on its second pass. Short names spread badly with that modulus (19 slots apart, against 147 at 256), which made it worse. At the default split, 250 of 256 survived repeated passes.ClearVars empties it and any run of markers just before it. After a full unwind the region is clean. Test: Bas/bench/locvars6.py.erase() leaves markers the same way. Peter judged it not reachable in practice (erased globals are reused and rare), so it is not changed.Also found, but not on main: CHAIN and SAVE/LOAD CONTEXT left stale symbol bindings on development (fixed there in 8d3ee25). Symbols exist only on development.
Not a bug: recursion from inside a FOR/NEXT or DO/LOOP corrupts the loop, which the manual documents (Recursion, page 41).