The G3 prototype showed that the interpreter's overhead is not in any one routine but in re-reading text. The same kernels run as typed wordcode were 8.8-22x faster than the interpreter with symbols, on both chips. This design turns that prototype into firmware without changing what a program means. The tokenised program stays the master copy. A compiler turns it into a stream of statement records, kept in a program slot. An executor runs the stream and leaves the interpreter's state exactly as the text interpreter would at every statement boundary. Anything not compiled runs through its existing command handler, so a program is always runnable, whatever the compiler has not learned yet.
Branch development at 8740953 · evidence: G3 (commit 3140045, Bas/bench/results/g3), the Phase 1 report, and four read-only code surveys for this design (statement loop and control flow; calls and variables; evaluator and built-ins; storage, lifecycle, interrupts and errors).
PrepareProgram(true). There, a CRC of the image is checked against the stamp in the stream slot, and the program is compiled only if the two differ. That covers every way a program arrives (LOAD, AUTOSAVE, EDIT, XMODEM, FLASH/RAM LOAD and RUN, CHAIN, LIBRARY LOAD) without touching any of the image writers.cmdline, nextstmt and cmdtoken the text loop would give it.g_vartbl and the heap and are reached through the S5/S6 bindings. FOR, DO, GOSUB and SUB frames use g_forstack, g_dostack, gosubstack and g_LocalIndex exactly as the text interpreter does. So interrupts, ON ERROR, CHAIN, SAVE CONTEXT and any fallback statement see nothing different.A 40-opcode wordcode VM was built as a CSUB with armcfgen and fed hand-compiled wordcode. It has typed int and float operations, bounds-checked arrays, BYREF pointers and a per-statement marker that does the interpreter's tail work. Four kernels ran three ways: interpreted, as wordcode, and as plain C.
| Kernel | PC3 interp / VM / C (ms) | Interp÷VM | VGA RP2040 interp / VM / C (ms) | Interp÷VM |
|---|---|---|---|---|
| Integer loop in a SUB | 468 / 23.6 / 1.1 | 19.8x | 864 / 45.0 / 5.1 | 19.2x |
| Insertion sort | 759 / 45.7 / 0.9 | 16.6x | 1345 / 82.5 / 2.1 | 16.3x |
| Julia inner loop | 730 / 62.3 / 19.8 | 11.7x | 1472 / 168 / 93 | 8.8x |
| findleap ×4000 | 830 / 49.2 / 9.3 | 16.9x | 2192 / 99 / 25 | 22.1x |
The prototype's figures are an upper bound for three reasons. Its variables were reached through pre-resolved addresses; its locals lived in its own frame; and its statement marker did less than the real tail. This design pays for all three to keep state identical, so the firmware result will be lower. P2 and P3 each measure it.
Runs inside PrepareProgram(true) when the image's CRC does not match the stream's stamp. It walks the tokenised image, the same bytes the text interpreter reads. It asks the interpreter's own code every structural question (which NEXT closes this FOR, where this IF's ELSE is, what this label resolves to) so the answers cannot differ. It writes the stream unit by unit, sequentially, straight into the slot.
A computed-goto loop, like the prototype's. ExecuteProgram hands over to it whenever the next statement lies in the program image and a live stream exists. The executor hands back when control leaves the image: library code, the prompt's tknbuf, EXECUTE, or the empty statement that ends a FUNCTION body.
The code that must give identical answers is factored out of the text interpreter and called by both paths: operators, conversions, the number parser, element addressing and the block scans. The interpreter's behaviour does not change; its code is moved, not rewritten.
Any statement the compiler does not handle becomes a fallback record, which calls commandtbl[token].fptr() or DefinedSubFun with exactly the globals the text loop would set up. P1 is nothing but fallback records. The compiled subset grows phase by phase behind the same interface.
A slot holds one stream: a header, a statement map, the code and a unit table.
| Part | Contents |
|---|---|
| Header |
|
| Statement map |
|
| Code |
|
| Unit table |
|
Every record begins with STMT. It carries the statement's text offset and a line-start flag, and does two jobs:
ClearTempMemory if g_TempMemoryIsChanged;CheckAbort and check_interrupt unless OPTION NOCHECK.nextstmt is set to this statement's text, which is exactly what the text loop has in nextstmt at that moment. So an interrupt's InterruptReturn and Ctrl-C's ContinuePoint come out the same.CurrentLinePtr to the line's T_NEWLINE, writes TraceBuff, and prints [n] under TRACE ON, as at MMBasic.c:2003-2031.Entering the executor starts at the head, skipping the tail, just as the text loop has no tail before its first statement. The tail's cheap tests are inlined: IntReady.any, MMAbort, the routinechecks 100 µs cadence and the ProcessWeb cadence on WEB builds. The functions are called only when a test says so, as the prototype's marker did.
After STMT comes one of:
cmdline and nextstmt offsets precomputed by the same skipspace/skipelement the loop uses. It sets cmdtoken, cmdline, nextstmt, CmdTokenPtr and targ = T_CMD, expands symbols for commands not on SymAwareNames, and calls the handler.FindSubFun then DefinedSubFun(false, ...) (MMBasic.c:2082-2091).After a fallback, if the handler left nextstmt at the precomputed end of its element (the common case), execution continues with the next record. Otherwise the new nextstmt is looked up in the cache and the map. If it lies outside the image, the executor returns it to the text loop.
PrepareProgram(true) runs for RUN, FLASH/RAM RUN and CHAIN, CHAIN, LIBRARY LOAD's restart, EDIT's save-and-run, and the boot autorun.PrepareProgram(false) never compiles.AS types, OPTION DEFAULT in text order);safe_flash_range_* wrappers.MAX_PROG_SIZE the compile is abandoned and the program runs as text. MM.INFO reports why.The compiler must match FOR to NEXT, DO to LOOP, IF to ELSEIF/ELSE/ENDIF and SELECT to CASE exactly as the handlers do at run time, including their odd cases. So those scans are factored out of the handlers and called by both:
NextNamesVar (Commands.c:5026-5168);IfTableLookup, already built by PrepareProgram (Commands.c:3241-3541);findlabel/findline for labels.Where a structure is irregular (NEXT naming two variables, a GOTO into a loop, a CASE list containing strings), the statements involved are compiled as fallback. The text handler then does exactly what it does today.
p lies in the program or library image, it calls RunStream(p). At the top rather than after p = nextstmt, so it also catches a loop's first statement and the statement after a comment line, which the comment fast path reaches with continue. That covers:
nextstmt = ProgMemory inside the prompt's ExecuteProgram(tknbuf);MM.END/MM.PROMPT calls;ExecuteProgram, from its first statement.RunStream returns the text pointer at which the text loop should continue. That happens when control leaves the image; the "\0\0\0" that cmd_endfun leaves in nextstmt ends the nested loop as it does now. END and errors leave by longjmp, as they do from any handler.gosubstack keeps its text pointers, because fallback RETURN, error()'s unwind and CHAIN read them. A parallel array of record addresses, indexed by gosubindex, lets a compiled RETURN or END SUB go straight back without a map lookup. The executor uses that record address only if the gosubstack text pointer is the one it recorded, so a frame pushed by text code is never misread.RunStream. It saves and restores ErrNext, nextstmt, cmdline, cmdtoken and CurrentLinePtr exactly as DefinedSubFun and getvalue do around their nested ExecuteProgram (MMBasic.c:2925-2934, 3545). The executor's C frame is kept small, because the core 0 stack is 8 KB and deep recursion already reaches it.Between statements, other code reads the following (survey 1, section 4). The stream keeps each one as the text loop would:
| State | Read by | How the stream keeps it |
|---|---|---|
CurrentLinePtr | errors, MMerrline, EDIT after an error, TRACE, the PC sampler, findlabel, RESTORE, check_interrupt (NULL means immediate mode) | STMT at each line start; set wherever GOTO, GOSUB, RETURN and ELSEIF set it; saved and restored around FUNCTION calls |
nextstmt | interrupts (InterruptReturn), Ctrl-C (ContinuePoint), fallback control flow | set by STMT before the tail; precomputed for each fallback |
cmdline, cmdtoken, CmdTokenPtr | NEXT, LOOP, IF and ELSE compare cmdline by address; cmd_if, cmd_do and cmd_subfun test cmdtoken | set by CMD exactly as the loop sets them: pointers into the image, and a symbol-expanded copy only for the commands the loop copies too |
FOR, DO and GOSUB stacks, g_LocalIndex | fallback NEXT/LOOP/RETURN/EXIT, ClearVars(level), CHAIN's SaveContext, interrupts | compiled FOR, DO, GOSUB and CALL push and pop the same entries with the same fields |
| ON ERROR SKIP state | error()'s longjmp(ErrNext) | see below |
| Temporary memory | GetTempMemory callers; the tail's ClearTempMemory | compiled string operations use the same allocator; STMT runs the same tail |
TraceBuff, TRACE ON | TRACE LIST, TRACE output | STMT at each line start |
OptionErrorSkip is non-zero, the executor switches to a variant that arms setjmp(ErrNext) around each statement, as the text loop does (MMBasic.c:2099-2112). On a caught error it restores g_LocalIndex and temporary memory, then continues at the record after the failing statement's map entry. The executor's position is kept in a global, not a register, so the longjmp cannot lose it.GotAnInterrupt redirects nextstmt to the handler's text; the executor maps it to the handler's record.nextstmt back to the interrupted statement, and the map finds it.check_interrupt mid-command and re-run their statement through InterruptReturn = CmdTokenPtr. They are fallbacks, so this is unchanged.ContinuePoint = nextstmt at the mark landing is a text pointer, so CONTINUE re-enters through the map.SymCanonOf is filled for every program id, so an operand reaches its canonical entry with one load.SymL[k] when g_vartbl[slot].level == g_LocalIndex.SymG[k] when !g_LocalIndex || !SymTextLocals. This is findvar's fast path (MMBasic.c:5203-5225), without the call and without the text.findvar on a short buffer holding the symbol bytes. It returns exactly findvar's result, including implicit creation and the OPTION EXPLICIT error.T_PTR against what the code was compiled for.T_PTR (BYREF parameters, STATIC, array parameters) is resolved during binding, so the operations themselves only ever see a data address.AS, FUNCTION AS);FINDVAR_DOTBLOCKS), and STATIC declarations. Statements using them are fallbacks. STATIC variables themselves are ordinary T_PTR locals once made, so reading them is compiled.doexpr does (MMBasic.c:3293-3398), from the same tokentbl[].precedence values, left-associative at equal precedence. Unary - + NOT INV bind tighter than ^ (-2^2 is 4). Operands are evaluated left to right, so user FUNCTION side effects occur in the same order./) convert integers to float;\ MOD AND OR XOR << >>) convert floats with FloatToInt64;compare(). The existing op_* functions and the executor both call these helpers.
FloatToInt64; subscripts use FloatToInt32./scale path and fast_strtod. The constants are therefore bit-identical to what the text path computes.OPTION COMPILE SHADOW). Each compiled LET right-hand side, IF/ELSEIF condition and FOR limit is also evaluated through the text evaluator on the statement's own text, and the two results are compared bit for bit.
| Statement | Compiled as | Kept identical |
|---|---|---|
| IF / ELSEIF / ELSE / ENDIF | conditional jumps; single-line IF with THEN/ELSE parts compiled inline | truth is value != 0 after the same conversion; CurrentLinePtr set at an ELSEIF as retest_an_if does |
| FOR / NEXT | start and limit evaluated once; a g_forstack entry pushed with its var, vartype, level, TO and STEP, forptr and nextptr; NEXT adds STEP, tests and jumps back | the same entry, so a fallback NEXT or EXIT FOR, ClearVars(level) and CHAIN all find it. A compiled NEXT checks that the top entry is its own and otherwise calls cmd_next. |
| DO / LOOP / EXIT DO | a g_dostack entry and jumps; the condition compiled | the DoFast state is left off; the entry's pointers are the text ones |
| SELECT CASE | selector evaluated once into an executor temporary; each CASE a compare chain | CurrentLinePtr set to each CASE line before its values are evaluated, as the scan does |
| GOTO / GOSUB / RETURN | direct jumps through the unit table; GOSUB pushes gosubstack/errorstack, does g_LocalIndex++ and EnterLocalFrame | ON … GOSUB/GOTO and computed targets stay fallbacks |
| EXIT FOR / CONTINUE FOR / DO | pops and jumps | as Commands.c:3160, 5654, 5663 |
AS types, BYVAL/BYREF, and array parameters. This is the work DefinedSubFun repeats on every call, splitting both argument lists with makeargs. Phase 1 put that at 21% of a call loop.errorstack and gosubstack are pushed (NULL for a FUNCTION).T_PTR to the caller's data, with the same type-mismatch demotion to a copy); expressions are passed by value.g_LocalIndex++, and EnterLocalFrame on SUBPROFILE builds.SymLocalMade runs as usual.CurrentSubFunName is set (STATIC relies on it).cmd_return and the FUNCTION epilogue do.DefinedSubFun as fallbacks: CSUBs, struct parameters, element-addressed array arguments such as a(3) passed as an array, missing arguments, array-returning FUNCTIONs, and CALL/CALL(). The compiled path covers the forms the corpus uses; the census counts them in P3.A built-in function or a graphics command reads its arguments from text: getnumber(ep), or getargs and then getnumber(argv[i]). The value splice gives them values already computed, without changing the handlers:
T_VALUE, followed by one index byte, refers to an entry in a small table of values in RAM. The byte comes from 0x08-0x1D, which never occurs in program text because tokenise turns control characters into spaces (MMBasic.c:1411-1416).getvalue learns this one case: it returns the entry and its type." ( ) , ' =, and 0. So makeargs, getclosebracket, skipexpression and skipelement pass a spliced argument through as an ordinary operand.T_VALUE i , T_VALUE j , … into a temporary buffer.
cmdline pointing at that buffer.tokentbl[f].fptr) is called with ep pointing at it, and its result is read from fret/iret/sret and targ.checkstring on keywords) keep those keywords as text in the buffer and splice only the numeric arguments.Bas/bench/rbsize.py): Exile 81 KB for a 55 KB program (1.44 times), PETSCII Robots 115 KB for 65 KB (1.72), the anchor 132 KB for 70 KB (1.85), Elite 303 KB for 103 KB (2.87). So the allocation is RB_STREAM_SLOTS program sizes, 3 (Peter, 29 September): every program the slots can hold compiles, even at Elite's density. MM.INFO(COMPILE) ends with SIZE bytes OF the allocation, and a program too big for it runs as text with "too big for the slot (n of m bytes)".PSRAMstream, PSRAMstreamsize in configuration.h): 944 KB with HDMIWEB's 144 KB slots, and never less than three program sizes (a static assertion: 384 KB + (5 + 3) x MAX_PROG_SIZE fits 2 MB). That is why MAX_PROG_SIZE is 208 KB on the variants with a bigger heap (PICORP2350, PICOUSBRP2350, PICOBTRP2350, PICOBTHRP2350, WEBRP2350), which give the stream 624 KB. It is written at memory speed and is lost at power-off. It is nobody's slot, so every RAM slot stays the user's; RAM slot 4 was used until Prince of Pico, which fills all five, showed why it could not be.RB_STREAM_FLASH, RB_STREAM_FLASH_SIZE): after it, not before, because the flash slots are found from PROGSTART. TOP_OF_SYSTEM_FLASH moves up by it, so the A: drive is that much smaller (on a 4 MB part it keeps 1.1 to 1.9 MB, by variant) and moves: a board updated to this layout finds its A: drive unformatted, as with any change of FLASH_TARGET_OFFSET. Every flash slot is the user's again (slot 2 held the stream until then). The area is rewritten only when the program has changed since its last compile, so an edit-and-run cycle costs one erase of it, as saving the program already does.| Item | Estimate | Note |
|---|---|---|
| Flash, P1 | 3-5 KB | executor loop, fallback records, map and cache, stamp and slot handling, OPTION COMPILE |
| Flash, P2-P5 | +12-20 KB | expression and statement compiler, typed operations, calls, splice. The prototype's 40-opcode VM was 3.3 KB on the M0+. |
| Flash headroom now | VGA 13.8 KB; HDMIWEB 22.9 KB | the RP2040 VGA will probably need one 16 KB step at P2 or P3; that is your decision when the size is known |
| Static RAM | < 1 KB | map cache 512 B, return-record array 200 B (MAXGOSUB 50), splice table, executor state |
| Executor in RAM? | 0 at first | the prototype ran from XIP flash on both chips and was still 9-22x faster. RAM placement on the RP2350 is tried only if P2 measures a gain worth its heap page (4 KB quantum). |
| Compile RAM (transient) | 1-8 KB | largest unit's offsets plus page buffers; taken before the program's own variables exist, and freed |
| Stream size | 0.2-0.7x image | P1 records are about 10 bytes plus a 6-byte map entry per statement: the anchor's 1,250 statements take about 20 KB against its 71 KB image. The G3 estimate for compiled code is 0.04-0.65x. |
| Compile time | to measure | one image walk plus two passes per unit; on the RP2040 also the erase and programming of up to 100-120 KB of flash, only after a change |
| Risk | Containment |
|---|---|
| A compiled result differs from the text result | shared helpers for every operator, conversion and literal; shadow mode on the goldens and games; the binding check turns a wrong type assumption into a fallback |
| Interpreter state differs at a boundary | the table in Staying identical; the interrupt tests (Bas/bench/inttests), CHAIN and CONTEXT (symchain.py), ON ERROR SKIP and TRACE cases added to the goldens run with OPTION COMPILE on |
| Structure matched differently (FOR/NEXT, IF/ENDIF, SELECT) | the compiler calls the handlers' own scans; irregular structures stay fallbacks |
| Stale stream | the CRC of the whole image and the firmware build hash are checked at every RUN-type entry; no writer has to remember the stream |
| Flash wear and time on the RP2040 | recompile only on change; runs batched as the plan says |
| C stack depth | nested FUNCTION calls add an executor frame where they now add an ExecuteProgram frame; the executor's frame is kept smaller, and recursion tests run on both boards |
| Build variants | the feature is gated per variant. PICOMIN is excluded. On the M0+ the executor is compiled at -O2, never -O3. Each variant's flash map is checked every phase. |
| Behaviour that depends on text being re-read (EXECUTE, EVAL, CALL "name", the prompt) | unchanged: that code is text and runs in the text loop |
MM.INFO(COMPILE) returns OFF, TEXT: why, or COMPILED (NONE once another program has been prepared) followed by compiles, reuses, records and RAN, the statements the last run executed from the stream. Test: Bas/bench/rbstamp.py; stdset.py --compile runs the standard set compiled.skipelement pre-scan and the token decode, which the records precompute. Expect 0-5%.LET g = literal and LET g = g2 for global integer and float scalars. OPTION COMPILE SHADOW evaluates each compiled right-hand side through the text evaluator as well and stops on a difference. Done. A compiled statement is its CMD record with RB_COMPILED and the code after it, so the record is its own fallback. A variable's first two assignments run the fallback (findvar makes it, then binds its symbol when it next finds it). Two assignments in a loop run 1.96x faster compiled; the goldens are clean in SHADOW mode. Test: Bas/bench/rbp2a.py.Steps, each built and run on the PC3 before the next:
P4c then found that the text path had lost a level since P3e (rbp3d's Fact(10) as text overflowed by 80 bytes), and 1.3 KB of RAM that did not need to be RAM. GCC had inlined MMBasic.h's FloatToInt64 into RBRun at each conversion op, and RBShadow, SHADOW's check, with its two 40-byte buffers, which were in RBRun's frame at every compiled call level too. They are calls now (MMBasic.c's FloatToInt32 and FloatToInt64 are in RAM already), and RunStream's TRACE print and its fallback SUB call are in flash. RBRun went from 5232 to 4116 bytes and RunStream from 872 to 660. RAM moves the stack only in 256-byte steps, because AllMemory, the first thing in BSS, is 256-aligned, and this was four steps: rbdepth now reaches 11 as text and 16 compiled (it was 9 and 11). The decision stands, with less at stake.
heapend = __heap_start + PICO_HEAP_SIZE, so every byte of code in RAM moves it up and takes a byte from the stack, for text and compiled alike: when P3c grew RBRun by 500 bytes, rbp2e's LibFact(10) overflowed compiled. Two changes gave the stack back: RC_CALL's work moved from RBRun to a flash function (RBCallSub: its time is findvar's, not the dispatch's), and RBExec is folded into RunStream (a frame less on every nested level), with ON ERROR SKIP's copy in flash. The RAM margin went from 1.5 to 2.5 KB. Bas/bench/rbdepth.py measures it: a recursive FUNCTION reaches a depth of 10 as text and 9 compiled (8 and 9 before the change; SUB recursion is 49 either way, the gosub stack's limit, as a compiled SUB call nests no C frames). Compiled FUNCTION calls (P3d) must cost less stack a level than the text path, not more: they can skip ExecuteProgram and the evaluator's frames, but RBRun's frame (400 bytes, most of it the value stack) must not stay on the stack across the call. Anything added to RAM from here on should be weighed against the stack it takes.run_phystest.py;run_phystest.py with OPTION COMPILE ON: met.Steps, each built and run on the PC3 before the next (Peter, 27 September: "carry on with P4"):
[PERF] counts of fallback records, reaches the G3 figures.Function FNS(U,V)) gained 2x instead of 3.7x. Any number of FUNCTION calls in a LET, an IF or ELSEIF condition, a single-line IF's THEN and ELSE LETs and an INC's value (P3d allowed one, with no variable read after it). What could send a call to DefinedSubFun is checked before the first runs: gosubindex, the same at every call of the statement, and OPTION DEFAULT, by an RC_GUARD that RBFinish puts first in the record with the union of the calls' needs (bit 0 a number for untyped parameters, bit 1 float and bit 2 integer for an untyped result; the code's jumps are relative, so the word can be inserted). A call that erases a variable or changes OPTION DEFAULT moves the bind stamp; RBCallFun compares it before and after and binds the statement's variables again (RBRebind, the entry bind loop shared through an always-inlined RBBindAll: RBRun's copy in RAM, RBRebind's in flash). One that no longer holds stops with "A FUNCTION changed a variable of this statement": the one difference from text, which would make the erased variable again. OPTION DEFAULT changed inside a call of the same statement stops likewise. INC with a call: GCC compiles cmd_inc's *p = *p + getnumber(...) reading the variable before the call (checked in the disassembly), which is the compiled order already, so a FUNCTION that changes the variable adds to its old value in both. RND, with or without its bracket (whose argument fun_rnd never reads), as RC_FN on a pushed cell; RND and the calls mark the statement (ncall) so SHADOW does not check it, since the text evaluator would draw another number or make the call again (now also in DO, LOOP and the splice's values). MAP( joins the function splice where the build has it (PICOMITEVGA or PICOMITERP2350): its handler reads a colour with getint and names no argument text in its errors, so PIXEL and LINE with a MAP colour compile (they had gone to text whole). COLOUR (COLOR) joins BOX, LINE and PIXEL in the command splice, with no LEGACY guard (getColour maps OPTION LEGACY's colours itself), and RGB( the function splice: three values as getint reads them, or one colour name, which the compiler spells out into the spliced text as getvalue spells it out for fun_rgb (names are symbols in the program), so its "Invalid colour: name" is the text path's. (In modes other than 1 to 5 the VGA/HDMI fun_map sets neither targ nor iret, so MAP returns whatever iret last held, as text or compiled: a side finding.) The galaxy demo: 255 us a point as text, 90 compiled with RND left to text, 80 with it (3.2x); the sampler puts what is left in the maths library (about 25%), RBRun (31%) and PIXEL's own argument handling (19%: the splice still runs cmd_pixel's getargs), the next candidate. On the RP2350 RndVal reseeds from the hardware RNG every 100 draws, so RANDOMIZE does not repeat a sequence: compare such programs per unit of work, not per pass. The stream's build id now has the build in it (Stream.c's __DATE__ __TIME__ and the image's end): PSRAM and the flash area keep a stream across a firmware update, and a new build with the same RB_VERSION reused an old build's stream. RAM: RBRun's step held on HDMIWEB (8 bytes spare) with the OPTION DEFAULT and LEGACY checks in one flash function and the rebind decision in RBCallFun. Tests: rbp3d.py (two and four calls, untyped as float and integer, OPTION DEFAULT moved by EXECUTE, DEFAULT NONE, INC with calls and with a FUNCTION that changes the variable, rebind after an ERASE, the erase-used difference); rbp5a.py's rnd.If i < 0 Then quit = 1 ran as text on every pass with quit = 0 or Dim quit before the loop. (1a) findvar binds a global as it makes it, as the next lookup would have; the 16 bytes crossed HDMIWEB's 256-byte step, so FindSubFunText went to flash (noinline: a symbol reaches it once a run, FindSubFun keeping the answer in SymS; only text names use it more), which bought the step back and one more. (1b) A single-line IF's THEN and ELSE assignments are compiled into the IF's record, whose start bound them too, so a variable that only an untaken part names held the whole IF back. Their binds that the condition does not make are optional (RB_BOPT in the bind's type word): RBBindAll leaves such a slot NULL and returns 2 (the bind cache is then not kept, so the next run binds again), and RC_PARTCHK in front of the part, with the mask of the optional binds it uses, sends the IF to its fallback only when that part is taken with one unbound. Only when the condition made no call and drew no RND (x.ncall): the text path then evaluates it again. The check is inserted in front of the part once its binds are known (RBPartCheck; the jumps are relative and the only one into the part lands at its start). RBRebind now errs only for a bind that held before the call and does not after. Measured: the never-made, never-taken IF 100 of 100 passes compiled (was 0), taken once 99. (2) &H, &O and &B literals are read by getvalue itself, as decimals are: its based reader raises nothing once the letter is H, O or B ("Type prefix", for another, stays the text path's). (3) OPTION COMPILE is saved: Option.Compile, one byte from the front of extensions[] (62 on HDMIWEB, 70 elsewhere; the struct stays 896), set by the command only when it changes, read at boot into RBMode, shown by OPTION LIST, carried by OPTION DISK SAVE/LOAD (OptionMap). Both PicoCFunctions.h copies follow the struct. (Side finding: they still end in extensions[75] where FileIO.h has extensions[71] and LIBRARY_HASH, same size and offsets, as on main.) (4) [PCSLINE] prints the line number the editor and error messages use (CountLines + 1; -1 for the library); pcs_report.py takes it as it is, --old for a recording from before. Tests: Bas/bench/rbp5d.py (bound as made, the IF parts, based literals); rbp2a's exact counts rise by a pass (a variable made on the first pass compiles from the second). Found by the goldens under SHADOW (byrefelem, 2d: 7 for 8): an element of a two-dimensional array passed to a SUB or FUNCTION was compiled as a value, a copy, so the callee's assignment was lost. RBArgs' test for an element alone, which DefinedSubFun passes by reference, found its end with RBArgEnd, which stops at the index's comma; it now steps past every index. In since P4a made elements compile as values; the P5a and P5c SHADOW goldens showed it among their timing differences and it was missed. rbp5d has the case, for a SUB and a FUNCTION.The observation. Only one SUB is active at a time (an interrupt handler is a SUB call like any other), and the names a SUB can make local are fixed by its text: a FUNCTION's own name, its parameters (by value, or a pointer to the caller's variable), and each LOCAL and STATIC. PrepareProgram can therefore list every SUB's locals and give each an offset. On entry one frame holds them all; a local is found by its offset, not by hashing its name; on exit the frame is dropped.
What it would remove. In the anchor's timed run (P5a, compiled) making and freeing locals and what follows from it came to about 15%: making them (findvar with V_LOCAL: the hash probe, the already-declared search, the check that no SUB has the name, filling a 64-byte entry, SymLocalMade) about 4.5%; freeing them (ClearVars walking the creation list, clearing entries, leaving blocked markers, LeaveLocalFrame) about 1.7%; the bind churn, since every local made or freed bumps SymBindGen and every compiled record re-binds after a call, about 6.5%; the LOCAL statements' own reading about 2.5%. Exile and PoP run 150,000-160,000 LOCALs each.
Where the frame lives. Not in a separate block of heap: the text interpreter reads g_vartbl[g_VarIndex] after findvar in 298 places across 14 files (a variable's type, CONST, size, dimensions, the source of a BYREF argument), and any statement of a SUB that runs as text finds its locals through findvar. The frame is therefore a run of variable-table entries: the local region (slots 0 to maxlocalvars - 1) becomes a stack of frames with a frame pointer instead of a hash table, so g_VarIndex stays a valid index and none of those 298 places changes. No heap call is made at all.
LOCAL a(n), LENGTH), and raise "already declared" if the entry already is in this call. Before its LOCAL runs, a name finds the global, as it does now.Scope and cost. It is a change to the text interpreter's own variable handling (findvar's local path, ClearVars, DefinedSubFun's parameters, cmd_dim for LOCAL and STATIC, the context save), shared with the RP2040, so text mode would gain too and there would be one kind of local, not two. It needs its own phase with the goldens and the interrupt tests on both boards. Proposed steps, each identical in behaviour and measured before the next: F1 the per-SUB layout at PrepareProgram; F2 the local region as a frame stack, findvar and ClearVars through it, text mode only; F3 parameters and LOCAL made into the frame; F4 Route B binding locals by offset.
What the map of the locals code found (28 September, every place that makes, finds, frees or saves a local), and how it shapes the steps:
g_hashlist[MAXLOCALVARS] against a definition of [MAXLOCALLIST]; "Too many local variables" clears every variable, globals too, before it is raised. (ERASE finding only globals, and SAVE CONTEXT belonging at the top level, are documented in the manual, and stay as they are.)&g_vartbl, so native code may scan it.Decided (Peter, 28 September): frames for every run, text and compiled. Each local stays its own variable-table entry, the frame being a run of them, so OPTION LOCAL VARIABLES keeps its meaning: the number of local entries, now the depth of the frame stack (every local of every call in progress), and "Too many local variables" means a frame that does not fit. A LOCAL made through EXECUTE is refused with an error, and the manual will say that LOCAL cannot be used in EXECUTE, and that no variable should be created through EXECUTE while OPTION COMPILE is on. The design is accepted; the steps F1-F4 follow Route B's P5 work.
F2, the local region as a stack (branch p6-frames, from tag pre-p6 so the phase can be backed out; every build, both chips). A new local is made in the next slot up (g_localtop), and the creation list, which already listed the live locals in the order they were made with their levels, is the stack's index. A local is found by name by walking that list down from the newest while the levels are not below the current one (FindStructBase the same way); symbols still find locals through SymL, so the walk is for creation and for names as text. ClearVars(level) frees the level's locals and every deeper one, newest first, with no hashing and no blocked markers; a full stack raises "Too many local variables" as the probe's wrap did, and the limit's error ("Not enough Local variable memory") comes at the same depth as before. One loose end is settled rather than carried: when ClearVars freed a local below one that stays (a deeper level's local left by an error, under a local of the level that goes on), the old walk dropped the survivor from the list, so it was never freed; the list is now compacted instead (LocalListCompact, in flash: only after errors). LOAD CONTEXT rebuilds the stack's height from the restored list; LIST COLLISIONS reports no local buckets. RAM: the first build crossed a 256-byte step; with the compaction in flash, __data_end__ is where it was (ClearVars 16 bytes smaller). Test: Bas/bench/rbf2.py, recorded on the build before and checked on F2 (recursion, hiding and nesting, local arrays and LENGTH strings, strings and a local CONST, a local CONST hiding a global one as the manual describes, a structure local in recursion, GOSUB with a SETTICK handler making locals, ON ERROR SKIP inside a SUB 500 times, the limit and a run after it, SAVE/LOAD CONTEXT around calls, forty locals); identical on the PC3 (compile OFF and ON) and on the RP2040 VGA (text). Speed, as expected small, since F2 removes only the hash probe and the markers: on the PC3, a text loop of SUB calls with six locals -0.8%, G3 K4 in a SUB -2.2% as text and -4.0% compiled, the anchor compiled 7.115 to 7.085 s and Elite compiled 11.38 to 11.31 ms a frame, text unchanged. On the RP2040 the same call loop is 3.7% slower (2850 to 2957 ms) and forty locals unchanged: findvar and the lookup are in flash there and every hot flash function moved, while the walk costs tens of cycles a call against 1,700 measured, so this is placement; F3 replaces that call path, and the RP2040 is measured again there.
F3a and F1, making locals and reading parameters (960ba97). Where the time went, on a PC-sampled anchor on F2: making locals (findvar with V_LOCAL, about 20 a call: the anchor's SUBs have many) and reading each definition's parameter list again on every call (makeargs, CheckByKeyword, skipvar, the AS type) were most of the call cost; text mode's findvar is mostly reading variables, which neither touches. F3a: for a name that is a symbol with bindings, the local search at a level is answered by the name's newest local (SymL) when that is at this level or below it, the walk remaining for a name as text, while a local made from text lives, and when the newest local is deeper (one left by an error); a local being made skips the global search, whose answer it never used; and the check that no SUB has the name runs once per name for the run (SYMC_NOSUB in its cold entry; on the RP2040 that check scanned every SUB for every variable made). findvar's two "Different type already declared" paths, each with its own copy of the symbol-name reader, became one flash function, so findvar is 240 bytes smaller than on F2. F1: DefinedSubFun reads a definition's parameter list on its first call, with the code that read it on every call (so its errors come as they did, at the definition), and keeps each parameter's text as findvar is given it, its BYVAL or BYREF, its AS type and the structure index that type left, and the body's first statement; the layouts live with the symbol bindings and are read again if subfun[] has changed, and without memory for them a call reads its definition into temporary memory as before. DefinedSubFun shrank, and AllMemory moved down a 256-byte step on the PC3 (the stack gains 256 bytes). The frame reserved at entry, with a fixed slot for every LOCAL a SUB has, was not needed by any of this and is not made: it would make "Not enough Local variable memory" come sooner for a SUB whose LOCALs do not all run, and F4 binds locals another way (Peter, 28 September: option 2). Test: rbf2.py grew a LOCAL named as a SUB, a suffix clash on a bound name, and the parameter forms (plain, typed, string, BYVAL, BYREF, array, structure, missing arguments, a string FUNCTION, recursion, and the three BYVAL/BYREF errors), recorded on F2 on the RP2040; identical on both boards. Against F2 on the PC3: the text anchor 15.86 to 15.65 s, compiled 7.09 to 7.10; Elite text 23.46 to 23.14 ms, compiled 11.31 to 11.23; a text loop of SUB calls 1638 to 1465 ms. F3a alone had made text 0.7-1.6% slower, which F1's build took back: placement. On the RP2040 the same call loop went from 2850 ms before P6 to 2570.
F4, binds kept across the calls a record's level makes (98062e8; option 2, Peter, 28 September). What a name binds to at level l changes only with an event that can change a global's binding, or with a local made or freed at level l itself: a call's, an interrupt's or a GOSUB's locals are gone before level l runs again. So one count numbers every such event (SymBindEvent); SymBindGenG holds the last global event's value and SymLevelGen[l] the last local event's at level l, and a record at level l keeps its binds while SymBindGenG + SymLevelGen[l] is unchanged (a sum, which still changes after the count wraps, where the larger of the two would not). Level 0 has no locals, so its stamp is option C's. No list of a SUB's local names is needed, and none of the frame machinery. RP2350 only: the RP2040 has no compiled records, and the counts' 320 bytes of BSS cost it a level of recursion (seen on COM7 before the guard). Test: rbcache.py grew a loop in a SUB around calls, a callee's local hiding a caller's global, a LOCAL made part way through a loop after calls, a SETTICK handler making locals during a SUB's loop, and a GOSUB's LOCAL in a SUB (OFF, ON and SHADOW alike); rbf2 unchanged on both boards. A loop in a SUB around calls went from 90.2 to 77.0 ms. The anchor did not move (7.10 to 7.11 s): its SUBs are straight-line bodies run once a call, whose records bind once a call because the call's locals are new; Elite compiled 11.23 to 11.16 ms. Binding a SUB's own records once per call is what remains of the bind cost.
How many locals programs use (b3e3f13: [PERF] reports local_peak, the local stack's high-water mark, against the region's size). The anchor 125 of 256; Exile 56 of 256; Elite 43 and PoP 44, both of which set OPTION LOCAL VARIABLES 128 themselves; PETSCII Robots (Peter's port, B:/petrobot on the PC3's card) 19 of 256. The local region is 256 entries of 64 bytes on the PC3 (16 KB) and 240 of 56 on the RP2040 (13.4 KB); since F2 it has no hash slack to keep, so its size is the most locals alive at once, and the list that indexes it (g_hashlist, still sized for the whole table, 704 entries on the PC3) needs only as many entries as the region.
192 local entries (c934ce4; Peter, 29 September). MAXLOCALVARS is 192 on every variant (was 256 on the RP2350s and 240 on the RP2040s), and MAXVARS shrinks with it: 4 KB of BSS on the PC3 and 2.7 KB on the RP2040 VGA. The stack check counts that room, so recursion goes deeper (PC3 FUNCTION depth 11 to 16 as text, 16 to 22 compiled; the RP2040's Fact(8), which overflowed, completes). The heap sizes stay as they were: on several variants MAX_PROG_SIZE is HEAP_MEMORY_SIZE, so raising a heap moves the flash layout and a firmware update would wipe A:. g_hashlist stays MAXVARS - 32 so that OPTION LOCAL VARIABLES keeps its range (a deeply recursive program can still take more locals from the globals). A program that lowers OPTION LOCAL VARIABLES got 64 fewer globals than before, and Prince of Pico, which sets 128, stopped with "Not enough Global variable memory"; so on the RP2350 MAXGLOBALVARS rose by 64 to keep the total (Peter, 29 September, option c: 544 on the VGA and HDMI builds, 576 on the others), and only the RP2040, where the RAM matters, keeps the smaller total (48 fewer globals for a program that lowers the option). The slots put three builds past the end of RAM, and each gave 4 KB of heap for them: HDMI 160 to 156 KB, HDMIUSB 152 to 148, WEBRP2350 252 to 248. On HDMI and HDMIUSB MAX_PROG_SIZE follows the heap, so their program slots are 4 KB smaller; the stream layout moves their flash anyway. The RP2040 builds went back to RC2's 240 (Peter, 29 September), all but WEB, whose RAM is 1.4 KB short of it: each local is 60 bytes there (a 56-byte vartbl entry and a g_hashlist entry), 2880 bytes for the 48, leaving PICO 1.2 KB of RAM margin, PICOUSB 2.2, VGA 0.8, VGAUSB 0.5 and PICOMIN 4.5. That margin is stack on the RP2040, so their recursion depth is RC2's again.
PETSCII Robots, timed by an instrumented copy (its main loop waits for a 100 ms heartbeat and then updates; the copy adds each update's time, and prints the count, mean and worst at the end): 718 frames in a minute's walk, an update 36.0 ms as text and 23.4 ms compiled (-35%), the worst 47.4 and 34.1 ms. Of the 5.7 million statements the stream ran, 49% were compiled; nearly all the text ones are the wait between updates (the input poll: a FUNCTION with STATIC, SELECT CASE on INKEY$, a CALL by name, and LOOP UNTIL Timer), which is spare time, not the update.
Each phase lands as small commits, each built and run on both boards. Full stdset and goldens runs happen at each gate, with functional checks between.
SymTabLib), added in P2 so library names bind like the program's; and library SUBs leave the list of DefinedSubFun fallbacks in P3.cmdtoken is an int holding a 14-bit command token; DefinedSubFun saved it as unsigned char tcmdtoken before ExecuteProgram ran the body and put it back after, so any command whose token is over 255 got it back truncated once a user FUNCTION had been called in its arguments.tcmdtoken is now an int. Route B's compiled FUNCTION call (P3d) always kept the whole value.realflashpointer, an offset in flash, with LibMemory, an XIP address, so the difference was always negative.realflashpointer's value when writing starts; main has the same change.save_psram_settings() (misc/FileIO.c), which disable_interrupts_pico() calls for WS2812, BITSTREAM, software serial, Onewire, audio and every flash write, cleaned the XIP cache by set/way at maintenance offsets 0 to 16K. RP2350-E11: a clean by set/way rewrites the cleaned line's tag with the maintenance address. So every PSRAM line holding unwritten data came back as a valid line for flash 0x10000000-0x10003FFF, where main, MMgetline, EditInputLine, MMPrintString, the start of IntToStr and SubLayoutFree are linked. The prompt then ran PSRAM data as code. The SDK's xip_cache_clean_all() cleans at the top of the XIP window to avoid exactly this. The code is in the repository's first commit, so every RP2350 release with PSRAM has it.flash_range_erase/flash_range_program already do a safe one and save and restore the PSRAM chip select's QMI registers. So fac5aa2 dropped the clean altogether (the QMI register save and restore stays), and stopped littlefs's sync hook calling the ROM flush, which invalidates without cleaning and so lost PSRAM writes still in the cache. Bas/bench/stackfault/psramflash.py checks that dirty PSRAM survives file writes, KILL, flash_do_cmd, WS2812 and BITSTREAM; its control step (an invalidate with no clean) loses 402 of 512 words. stfault.py again ran 120 times without a fault. Main has both changes (9754f64).: ' comment ran once.
FOR i = 1 TO 3 : ' c ran its body once, and DO : ' d left after one pass. A GOSUB line ending the same way returned past its next line.ExecuteProgram skipped to the line after the last T_NEWLINE crossed, but NEXT, LOOP and RETURN come back mid-line.FOR i = 1 TO 3 ' c) the comment is part of the FOR statement and was never affected.DIM z = x * 10 + y printed 0 in the chained program.SaveContext/RestoreContext. Test: Bas/bench/symchain.py, which covers CHAIN and SAVE/LOAD CONTEXT.MAX(-1e300, -2e300) gives -3.402823466e+38, and MIN(1e300, 2e300) gives 3.402823466e+38.fun_max_min starts from -FLT_MAX or +FLT_MAX (Functions.c:1855).ABS(1e19) raised "Number too large".
fun_abs and fun_sgn evaluate their argument with an integer target, so a float beyond 263 goes through FloatToInt64 (Functions.c:555, 1232).FloatToInt64 let NaN and exactly 263 through to an undefined C conversion. Fixed on main and development, a40112b.op_subtract had no infinity check, unlike add and multiply. Fixed on development, adb769f, and main, b9530d9.g_vartbl[0] for an expression argument, so "5" passed BYVAL was refused as an array when slot 0 held one. It is now checked only for a variable argument, in a flash helper. Fixed on development, 0887ad9 (test: Bas/bench/rbp5d.py), and main, 80f39be, where the check stays inline. On main, slot 0 is the local whose name hashes to it: with 34 local arrays under OPTION LOCAL VARIABLES 40, RC2 refuses Inner 5, x() and main accepts it.Route B reproduces each of these exactly, because it calls the same helpers. Fixing one changes both paths at once.