Route B · design for review

A compiled statement stream

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

Summary

Evidence (gate G3, 2026-09-25)

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.

KernelPC3 interp / VM / C (ms)Interp÷VMVGA RP2040 interp / VM / C (ms)Interp÷VM
Integer loop in a SUB468 / 23.6 / 1.119.8x864 / 45.0 / 5.119.2x
Insertion sort759 / 45.7 / 0.916.6x1345 / 82.5 / 2.116.3x
Julia inner loop730 / 62.3 / 19.811.7x1472 / 168 / 938.8x
findleap ×4000830 / 49.2 / 9.316.9x2192 / 99 / 2522.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.

The shape

Compiler

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.

Executor

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.

Shared helpers

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.

Fallback

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.

The stream

A slot holds one stream: a header, a statement map, the code and a unit table.

PartContents
Header
  • Magic "RB01" and the format version.
  • A firmware build hash: opcode numbers and helper indices are private to a build, so a stream from another build is stale.
  • The stamp: the image's length and CRC32, and its SymTabProg count.
  • The offsets and sizes of the other parts.
Statement map
  • One entry per statement, sorted by text offset: text offset (24 bits) to record offset (24 bits), 6 bytes each.
  • Used only when control arrives from a text pointer: an interrupt, IRETURN, a fallback that changed nextstmt, or ON ERROR SKIP.
  • A 64-entry direct-mapped cache in RAM keeps a loop driven by a fallback NEXT from searching it every pass.
Code
  • 16-bit words: opcode in the low byte, a small operand in the high byte, extension words after it, as in the prototype.
  • Records appear in text order, one per statement. Jumps are relative, so the stream runs from any address.
  • Integer and float constants are inline, parsed at compile time by the interpreter's own number parser.
Unit table
  • For each SUB/FUNCTION (by subfun[] index): the entry record, and the compiled parameter list (see Calls).
  • For each label the program jumps to: its record.

The statement record

Every record begins with STMT. It carries the statement's text offset and a line-start flag, and does two jobs:

  1. The tail of the statement before it. This is what the text loop does after each statement (MMBasic.c:2114-2126):
    • the ON ERROR SKIP count;
    • ClearTempMemory if g_TempMemoryIsChanged;
    • the core 1 stack check;
    • CheckAbort and check_interrupt unless OPTION NOCHECK.
    Before any of these, 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.
  2. The head of this statement. At a line start it sets 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:

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.

Compiling

When

How

  1. Survey pass. One walk over the image finds:
    • the units (the main program, and each SUB and FUNCTION);
    • each unit's parameter list;
    • every declaration's type (DIM, LOCAL, STATIC, CONST, AS types, OPTION DEFAULT in text order);
    • the statement count, which sizes the map;
    • anything that makes a unit unsafe to compile beyond fallback, such as a GOTO into the middle of a loop body from outside it.
  2. Per unit, two passes.
    • The first pass measures each statement's code and records its offset in a table for that unit.
    • The second emits, with every forward jump inside the unit already known.
    • Jumps between units go through the unit table.
    There is no whole-program parse tree and no global fix-up list. Compile RAM is the largest unit's offset table (4 bytes a statement), two 256-byte page buffers (code and map), the header page and the unit table.
  3. Output is written sequentially: code pages and map pages as they fill, and the header last.
    • On the PC3 the slot is PSRAM, written directly.
    • On the RP2040 it is flash: the slot's sectors are erased as the write reaches them, and pages are programmed with the existing safe_flash_range_* wrappers.
    • If the stream would exceed MAX_PROG_SIZE the compile is abandoned and the program runs as text. MM.INFO reports why.

Structural answers come from the interpreter

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:

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.

Running

Staying identical

Between statements, other code reads the following (survey 1, section 4). The stream keeps each one as the text loop would:

StateRead byHow the stream keeps it
CurrentLinePtrerrors, 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
nextstmtinterrupts (InterruptReturn), Ctrl-C (ContinuePoint), fallback control flowset by STMT before the tail; precomputed for each fallback
cmdline, cmdtoken, CmdTokenPtrNEXT, LOOP, IF and ELSE compare cmdline by address; cmd_if, cmd_do and cmd_subfun test cmdtokenset 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_LocalIndexfallback NEXT/LOOP/RETURN/EXIT, ClearVars(level), CHAIN's SaveContext, interruptscompiled FOR, DO, GOSUB and CALL push and pop the same entries with the same fields
ON ERROR SKIP stateerror()'s longjmp(ErrNext)see below
Temporary memoryGetTempMemory callers; the tail's ClearTempMemorycompiled string operations use the same allocator; STMT runs the same tail
TraceBuff, TRACE ONTRACE LIST, TRACE outputSTMT at each line start

Variables and types

Expressions

Control flow

StatementCompiled asKept identical
IF / ELSEIF / ELSE / ENDIFconditional jumps; single-line IF with THEN/ELSE parts compiled inlinetruth is value != 0 after the same conversion; CurrentLinePtr set at an ELSEIF as retest_an_if does
FOR / NEXTstart 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 backthe 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 DOa g_dostack entry and jumps; the condition compiledthe DoFast state is left off; the entry's pointers are the text ones
SELECT CASEselector evaluated once into an executor temporary; each CASE a compare chainCurrentLinePtr set to each CASE line before its values are evaluated, as the scan does
GOTO / GOSUB / RETURNdirect jumps through the unit table; GOSUB pushes gosubstack/errorstack, does g_LocalIndex++ and EnterLocalFrameON … GOSUB/GOTO and computed targets stay fallbacks
EXIT FOR / CONTINUE FOR / DOpops and jumpsas Commands.c:3160, 5654, 5663

Calls

Built-ins and the value splice

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:

Where the stream lives

Size, RAM and time

ItemEstimateNote
Flash, P13-5 KBexecutor loop, fallback records, map and cache, stamp and slot handling, OPTION COMPILE
Flash, P2-P5+12-20 KBexpression and statement compiler, typed operations, calls, splice. The prototype's 40-opcode VM was 3.3 KB on the M0+.
Flash headroom nowVGA 13.8 KB; HDMIWEB 22.9 KBthe 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 KBmap cache 512 B, return-record array 200 B (MAXGOSUB 50), splice table, executor state
Executor in RAM?0 at firstthe 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 KBlargest unit's offsets plus page buffers; taken before the program's own variables exist, and freed
Stream size0.2-0.7x imageP1 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 timeto measureone 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

Risks and how each is contained

RiskContainment
A compiled result differs from the text resultshared 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 boundarythe 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 streamthe 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 RP2040recompile only on change; runs batched as the plan says
C stack depthnested 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 variantsthe 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

Phases and gates

P1plumbing

Records only

Stamp and slot handling, the compiler's survey and emit skeleton, STMT/CMD/SUBCALL records, the map and its cache, the executor's entry and exit, and OPTION COMPILE ON|OFF.

  • Built (core/Stream.c): P1a OPTION COMPILE and the slot guard, P1b the stamp, P1c the records and the map, P1d the executor, with NOP records for comment and label-only lines. 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.
  • Gate status, 27 September 2026 (results in Bas/bench/results/routeB):
    • Behaviour: met. With OPTION COMPILE ON the goldens match on both boards; only printed timings, the PC3's real RTC status (rtcreg) and rtest's dependence on wtest's file differ. The interrupt tests, symchain, cmtloop and every stdset CHECK are unchanged, and the Exile and Elite checks pass compiled. TRACE and ON ERROR SKIP print the same as text.
    • Prince of Pico could not run compiled while the stream sat in RAM slot 4, since it loads its artwork into all five RAM slots. The stream now has a PSRAM region of its own, and PoP passes all 274 checks compiled.
    • RP2040: with the executor in flash the standard set ran 27-62% slower compiled (XIP thrash with findvar), and RAM for it would have cost a 4 KB page of heap. Route B is now RP2350-only.
    • Speed on the PC3, accepted by Peter with the gap left to P2. Three changes took the games from Exile +6.0%, Elite +3.2%, PoP +8.2% to Exile +3.0%, Elite +1.1%, PoP +5.3%, and the solar eclipse anchor from +2.1% to level (15.767 s text, 15.766 s compiled): a bucket index beside the map, so a cache miss costs a bucket read and one or two entries instead of an 11-probe binary search in PSRAM; the executor's position in locals rather than volatile globals, with ON ERROR SKIP's setjmp in a helper's own frame; and lookups that find nothing remembered (a true single-line IF sends nextstmt into its own element). What is left is the round trip to the text loop for the part after THEN and similar, which P2c removes by compiling IF.
    • Placement moves text speed between builds. pixart as text on the VGA: 10.8 s (merge build), 12.9 s (P1 with Route B code), 11.2 s (Route B removed from the RP2040); sombrero on the PC3: 11.3, 12.4 and 13.0 s over three builds. Nothing in the text path changed. Compare OFF and ON on the same firmware only.
  • The only possible gains are the per-statement header walk, the skipelement pre-scan and the token decode, which the records precompute. Expect 0-5%.
  • Gate:
    • goldens identical on both boards with OPTION COMPILE ON;
    • interrupt tests, symchain, cmtloop and the three games' checks unchanged;
    • stdset shows nothing slower than text beyond the 1% placement floor.
P2numbers

Numeric statements on globals

The expression compiler, the shared helpers and shadow mode; compiled LET, IF/ELSEIF/ELSE, FOR/NEXT, DO/LOOP and EXIT on global numeric scalars.

  • Steps, each built and run on the PC3 before the next:
    1. P2a, the compiled record. A CODE record after STMT carries the statement's binds, its wordcode and the fields of its fallback. The executor binds each variable (SymG through the canonical entry, the type and T_PTR checked, no local of that name live), runs the fallback if any bind fails, and otherwise runs the wordcode: computed goto, 16-bit words, a stack of 64-bit cells, as the G3 VM. First statements: 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.
    2. P2b, expressions. Operator bodies move out of Operators.c into value-level helpers that op_* and the VM both call; literals go through getvalue's own number parser. Compiled: + - * / \ MOD ^, the comparisons, AND OR XOR NOT, unary minus, brackets, with doexpr's precedence and promotion. Shadow mode on the goldens and the games. Done, without moving code: float and integer + - * run inline with op_add's and op_mul's overflow check, and every other operator calls its own op_ function with the arguments doexpr would give it, so the answers cannot differ; literals are read at compile time by getvalue itself. An expression loop runs 2.96x faster compiled. The games pass in SHADOW mode but compile almost nothing yet: they type their variables by declaration and OPTION DEFAULT, which the next step (the survey) reads. Test: Bas/bench/rbp2b.py.
    3. P2b+, the survey. Done. One walk before the first pass reads every declaration so an unsuffixed name compiles with the program's type: DIM with a type word or AS, a suffix, CONST with a literal; OPTION DEFAULT is followed in text order. A name that is a local anywhere (LOCAL, STATIC, a parameter, a FUNCTION's name, a CONST inside a SUB) is not compiled in P2, since its bind would fail at every run; the first survey, without that, made Exile slower (+4.85%) by compiling statements whose binds always failed. A name with no visible declaration under DEFAULT NONE (Elite's globals are declared in its library) is guessed float and checked. Exile now runs 71,568 statements compiled and is 2.1% behind text (P1: 3.0%); all three games pass in SHADOW mode.
    4. P2c, IF. Multi-line IF/ELSEIF/ELSE/ENDIF through IfTableLookup, and single-line IF with its THEN and ELSE parts compiled inline, which also ends the round trip to the text loop that costs P1 most of its remaining gap. First half done: a multi-line IF whose next arm is ELSE or ENDIF (the false target from IfTableLookup at compile time) and a single-line IF whose THEN and ELSE parts are assignments; false with no ELSE skips the rest of the line as cmd_if's skipline does. An IF-heavy loop runs 3.4x faster; PoP compiles 134,000 statements (was 58,000); goldens and games clean in SHADOW mode. ELSEIF chains and IF ... GOTO stay text. Test: Bas/bench/rbp2c.py. Second half done: records for a single-line IF's THEN part (no ELSE) and ELSE part, marked RB_PART and skipped when running on, so cmd_if's jump into its own element stays in the stream whatever the part is. More now runs from the stream (PoP 2.98M statements, was 2.82M) but the games' gap hardly moved (Exile +2.0%, PoP +5.5%): the round trips were not the cost.
    5. The tail's cadence. Done. A PC-sampled compiled loop spent 39% of its time in CheckAbort and check_interrupt, called after every statement as the text loop does (ProcessWeb 10.5% with WiFi up, routinechecks and its 64-bit timer read, OSK_IsActive). The stream now calls them at once when MMAbort or IntReady says so, and otherwise every 100 us, routinechecks' own cadence; ProcessWeb still polls at least every ms. Interrupt tests unchanged, Ctrl-C stops a compiled loop at once. Compiled loops 3.0-4.8x faster than text; the games are now faster compiled than text (Exile -3.2%, Elite -2.3%, PoP +1.3%).
    6. P2d, loops. FOR/NEXT keeping g_forstack as the text path does, DO/LOOP with g_dostack, EXIT FOR, EXIT DO and CONTINUE. FOR done: cmd_for's stack work and evaluation in its order, its NEXT found at compile time by cmd_for's own scan, now ForFindNext in Commands.c; NEXT, EXIT FOR and CONTINUE stay fallbacks. Binds name variables by symbol id (SymCanonById), one load instead of decoding the symbol's bytes, and from anywhere. K1 of the G3 kernels at program level runs 6.4x faster than text (5658 -> 883 ms), past the gate's 5x. The games are now all at or ahead of text (Exile -3.6%, Elite -2.5%, PoP -0.1%). Test: Bas/bench/rbp2d.py. DO and LOOP done: cmd_do's stack work (the DO fast path left off: the compiled LOOP evaluates the condition, and a fallback LOOP the text) and entry test, its LOOP found by cmd_do's own scan, now DoFindLoop; LOOP finds its entry as cmd_loop does and evaluates the DO's condition (compiled again at the LOOP, binds being by id), its own, or none. K3 (Julia, floats, DO WHILE) 4.38x, short of 5x: binding is 28% of its time and the dispatch switch 18%. The games are now all faster compiled: Exile -4.7%, Elite -3.1%, PoP -1.6%.
    7. The dispatch and the bind cache. Done. The VM dispatches by computed goto through a table in RAM (the switch was 18% of K3), and SHADOW's checks are emitted only when compiling in SHADOW mode. Binding was a quarter of a compiled loop's time. In a stream in PSRAM each compiled record now keeps the addresses its binds found, stamped with SymBindGen, and uses them again while the stamp matches. SymBindGen is bumped by everything that can change what a name binds to: new bindings, every variable gone, one erased, a local made or freed, and a change of OPTION DEFAULT. A live local made from text, which may hide a global, sends the record to its binds. A stream in flash cannot be written, so there every record binds each time. A stream used again has every stamp cleared first. K1 runs 7.76x faster than text (734 ms), K3 6.32x (240 ms). The games compiled against text: Exile -4.3%, Elite -1.5%, PoP +0.7% (inside the 1% noise).
    8. P2e, libraries. LIBRARY SAVE writes symbols, so library names bind like the program's. Done. LIBRARY LOAD saves the library's names as library symbols (0x06 and 0x07), counted over exactly the lines it writes, with a table of their own after the 0xFFFFFFFF marker and ahead of the CSUB and font records. The bindings give library symbols canonical entries after the program's (SymCanonLibBase plus the library's id), shared by spelling, so a name is one variable wherever it is read, and library code binds as the program's does (its locals were text locals before, which stopped global bindings being trusted inside any SUB while one lived). Everything that walks a library image reads the table: the boot check (LibraryImageValid), LIBRARY DISK SAVE's byte count, LIBRARY SAVE (which keeps the old library's symbols and carries its table over; the program it adds is still saved as text), and RAM LIST and FLASH LIST, which now spell a slot's names from the slot's own table. A RAM library saved by older firmware is read again (RAMLIB_MAGIC "RLI2"); a flash library saved by older firmware stays text and runs as before. Compiled records bind library symbols (RC_LIB), and the survey reads the library's declarations as well as the program's. Test: Bas/bench/rbp2e.py (RAM, flash, a restart, LIBRARY SAVE on top, DISK SAVE/DELETE/DISK LOAD, each OFF, ON and SHADOW). Elite, whose kernel is a RAM library, now runs 26,979 statement executions compiled where it ran 4,905, and runs 3.0% faster compiled than as text (was 2.5%); Exile -4.5% and PoP -0.2% are unchanged. Most of Elite's time is in SUBs with locals, which is P3's work. Shadow mode is clean on the goldens and the games.
  • Gate:
    • shadow mode clean on the goldens and the games;
    • the G3 kernels rewritten at program level are at least 5x faster than text on the PC3, which shows the prototype's speed survives in firmware (Route B is RP2350-only).
  • Gate status, 27 September 2026: both met. With OPTION COMPILE SHADOW the PC3's goldens differ from text only in printed timings and the real RTC, with no SHADOW errors, and the three games pass their checks (Exile 18 of 18, Elite's demo, PoP 274 of 274). K1 is 7.76x and K3 6.32x faster than text. P2e (libraries) comes next, before P3.
P3calls

Locals and calls

LOCAL, parameters, compiled CALL, FUNCTION calls in expressions, END/EXIT SUB/FUNCTION.

Steps, each built and run on the PC3 before the next:

  1. P3a, statements on locals and parameters. Done. When the walk reaches a SUB or FUNCTION it lists the unit's locals: its parameters (a suffix or AS gives the type; BYVAL and BYREF are stepped over; an array parameter is listed and never binds as a scalar), a FUNCTION's own name (its result), and every LOCAL, STATIC and CONST in the body. Inside the unit a name on the list compiles with its local type, and any other name as a global; "a local somewhere" no longer stops a statement compiling. A unit the walk cannot list (a header it cannot read, more than 64 locals) compiles globals only, as P2 did. The executor binds a local at this level, else the global when no text local can hide it, which is findvar's fast path; a T_PTR local (a BYREF parameter, a STATIC's alias) binds to the data it points to, as findvar returns it. CONST became a symbol-aware command: its locals had been made from text, so they could not be bound, and while one lived no global binding was trusted inside any SUB, on the text path too. Test: Bas/bench/rbp3a.py (local types, parameters of every kind including a type-mismatched variable argument that becomes a copy, shadowing, recursion, STATIC and CONST, EXIT inside loops, OPTION DEFAULT INTEGER, a BYREF chain through two SUBs, OPTION EXPLICIT). Results on the PC3: compiled against text, Exile -22.7% (12.18 to 9.43 ms a frame), Elite -20.2%, PoP -7.9% (were -4.5%, -3.0% and -0.2%); compiled statements are now 39%, 31% and 26% of what the games execute. The G3 kernels as written in SUBs: K1 7.88x, K3 5.97x.
  2. P3b, compiled SUB calls. The compiler reads the SUB's parameters once (DefinedSubFun splits both lists with makeargs on every call; with CheckByKeyword, skipvar and strchr that was about half a call's time) and the call's arguments. An expression compiles to its value; a variable binds, as a target, so that a CONST (which DefinedSubFun passes by value) goes to the fallback. RC_CALL then does what DefinedSubFun does for a SUB, in its order: g_FunReturnArrayCount, the error stack, substack (STATIC), the gosub stack, g_LocalIndex, the profiling frame and g_current_sub_idx (SUBPROFILE), then each parameter made by findvar exactly as DefinedSubFun makes it, and a pointer or a converted copy as it decides (a variable of the parameter's type by reference unless BYVAL, anything else converted). CurrentLinePtr is the definition until the first parameter is made and the caller after that, as DefinedSubFun leaves it, so an error is reported on the same line. What would stop DefinedSubFun before it starts (too many nested calls, an untyped parameter under OPTION DEFAULT NONE or STRING) sends the call to it. An error while the parameters are made unwinds through DefinedSubFun's own recovery, which now knows a compiled call by DefinedSubFunMem = 2 (no argument block to free). Left to DefinedSubFun: CSUBs, a FUNCTION called as a SUB, arguments in brackets, a missing argument, an array element or a call as an argument, strings, arrays, structures, BYREF with an expression or an untyped parameter. HDMIWEB took its 16 KB flash step here (1520 to 1536 KB), which wipes the A: drive on upgrade. Test: Bas/bench/rbp3b.py (every argument form, BYREF errors, a CONST argument, nested BYREF, recursion, STATIC, EXIT SUB, argument-count errors, an error inside the call). Bas/bench/rbcall.py times the calls: a SUB with three arguments 43.1 us a call as text, 30.7 compiled in P3a, 15.7 now; with none 14.5, 7.5, 5.0.
  3. P3c, the statement loop. A PC-sampled compiled K3 spent a quarter of its time between statements: RunStream's record walk and line bookkeeping, RBTail on every statement, and RBRun's entry and exit. Now, when a compiled statement is done and the text loop's tail would have nothing to do (no abort, interrupt or housekeeping due, no temporary memory to clear, core 1's stack intact, no TRACE, no ON ERROR SKIP), and the next record (the one after, past any IF parts, or nextstmt's) is a compiled statement, RBRun does that tail and the next record's line bookkeeping as RunStream would, and runs it; a loop's body, and a compiled call into a SUB, never leave RBRun. RBRun hands RunStream the next record it found, so a jump's target is looked up once, and the tail's checks run only when the next record can be chained (the first version checked first and slowed every statement whose successor was text). NEXT is compiled: RC_NEXT is cmd_next line for line (the loop found by the NEXT's position, the step, the test, the loop closed or back to the body). Two smaller changes from the profile: IF's float truth tests the bits (sp.f == 0 was a library compare through a veneer), and an integer literal used as a float is converted when compiling. G3 kernels in SUBs: K1 9.03x (the gate's 8x), K3 6.60x; at program level K1 8.94x, K3 6.68x. An empty FOR loop runs 5.2x faster than text.
  4. P3d, compiled FUNCTION calls. A call to a user FUNCTION in an expression, as getvalue makes it (a name with a bracket straight after it), compiles: RC_FCALL does what DefinedSubFun does for a FUNCTION, in its order (the frame and parameters as for a SUB, shared with RC_CALL; the result variable made after DefinedSubFunMem is cleared, as DefinedSubFun makes it; the body run by a nested ExecuteProgram; nextstmt, cmdtoken and cmdline put back; the result read; the locals cleared and temporary memory marked), and its value replaces the arguments on the VM's stack. Once a call has run, the statement cannot go to its fallback, which would make the call again, so the first version is narrow: calls compile only in LET and IF (FOR and DO have done their stack work before their expressions), one call a statement, arguments free of calls, and no variable used after the call but a LET's own target, which cmd_let finds before it evaluates (the callee may erase or make variables); SHADOW does not check a statement with a call, as the text evaluator would make the call again. A FUNCTION without a type (OPTION DEFAULT's), a string or array result, and whatever RBCompileCall leaves, stay with DefinedSubFun. A bug found on the way and fixed before this was committed: the text path checks the stack (TestStackOverflow) only in its evaluator (doexpr, getvalue) and argument splitters, so a recursion through compiled calls never met the check and ran the stack into the heap without an error (D(12) "survived" where the text path stops at depth 11); RBCallFun now makes the same check at every call. Test: Bas/bench/rbp3d.py (result types, BYREF and converted arguments, IF conditions, recursion, a global changed by the callee after it was read, two calls in a statement, an untyped FUNCTION, nested calls, no arguments, STATIC and EXIT FUNCTION, a local of the caller by reference, an error inside, and a count of the calls made: SHADOW makes none twice). A FUNCTION with two arguments: 41.6 us a call as text, 32.9 compiled before, 16.2 now. rbdepth.py (now without ON ERROR, which sends every statement to the text path): a recursive FUNCTION reaches a depth of 10 as text and 12 compiled.
  5. P3e, the VM's fast paths. From the PC-sampled compiled K3. The comparisons, and AND, OR and XOR on integers, are inline ops (RC_CMPF, RC_CMPI, RC_BITI): compare()'s answer taken from the bits of the difference (its sign, 0 for 0 and NaN) with no library compare, then the operator's own test (op_ne and op_equal compare integers directly, as they do). The INFINITY checks after + and * and NOT's zero test read the bits (each was a library compare through a veneer). The chain looks at what can change under it (ON ERROR SKIP, temporary memory, TRACE, all of which a FUNCTION's body may change) every statement, and at an abort, an interrupt, core 1's stack canary and the housekeeping timer once in 8 statements, a few microseconds; the timer read alone had been 5% of K3. The bind cache is used where it is: its addresses are 32-bit words on a 4-byte boundary (a pad word before them when the record needs one), so a hit points the VM at them instead of copying them to RBRun's frame. The inline ops cost 500 bytes of RAM, which the text path's recursion paid for at once (rbp3d's Fact(10) overflowed with OPTION COMPILE OFF); FOR's and DO's stack work, once a loop, moved from RBRun to flash functions and gave it back. G3 kernels in SUBs: K1 10.70x, K3 8.58x (the gate's 8x); at program level K1 10.66x, K3 8.92x. Recursion depth 10 as text, 12 compiled.
  6. For Peter's decision (27 September): Route B's RAM is the stack's. Route B held about 7 KB of RAM (RBRun's 5.2 KB of code, RunStream, RBFind, the map cache), and every byte of it is a byte less stack for every program, text or compiled. A build with Route B's code in flash measured what that costs and what it buys:
    • a recursive FUNCTION reaches a depth of 17 as text and 20 compiled with Route B in flash, 9 and 11 with it in RAM;
    • the tight kernels hardly care (K1 9.9x against 10.3x, K3 8.5x against 8.6x, K2 8.4x against 8.6x), but everything that mixes compiled and text statements does: K4 2.2x against 4.4x, a SUB call 8.1 against 5.5 us, and the games compiled against text Exile -5%, Elite -10%, PoP -1.5% in flash against -34.5%, -33%, -19% in RAM.
    So Route B must run from RAM. The options: accept the lost depth; or give the stack back by shrinking HEAP_MEMORY_SIZE on the RP2350 builds by 8 KB (two 4 KB steps), which on these builds is also MAX_PROG_SIZE (the largest program, 144 to 136 KB on HDMIWEB) and moves the flash layout, so upgrading wipes A:. Meanwhile the compiler's state went to temporary memory (600 bytes back), and new ops keep their cold parts in flash functions.

    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.

  7. Found in P3a and P3c: the stream costs C stack, and its RAM costs stack too. RunStream and RBExec added about 80 bytes to each level a FUNCTION call nests (the text path's own chain is ExecuteProgram 216 bytes, the statement, evaluate, doexpr and getvalue 360, DefinedSubFun 192); this has been so since P1. And the stack's floor is 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.
  • Gate:
    • the Exile kernel matches all 2,384 ticks of run_phystest.py;
    • the anchor improves on both boards;
    • the G3 kernels as written (in SUBs) are at least 8x faster.
  • Gate status, 27 September 2026 (P3e, on the PC3):
    • The Exile kernel matches all 2,384 ticks of run_phystest.py with OPTION COMPILE ON: met.
    • The anchor improves: 16.02 s as text, 14.94 s compiled (-6.7%), its output golden both ways: met on the PC3. Route B is RP2350-only since P1, so there is no second board.
    • The G3 kernels as written in SUBs: K1 10.7x and K3 8.6x, met. K2 (insertion sort) and K4 (findleap) do their work on arrays, which are P4's, so they are judged at P4.
    • The games compiled against text: Exile -23.5%, Elite -18.9%, PoP -12.2%. Shadow mode is clean on the goldens and the games.
P4breadth

Arrays, SELECT, GOTO/GOSUB, simple strings

Element addressing through the shared helper; SELECT CASE; labels; string assignment, concatenation and comparison.

Steps, each built and run on the PC3 before the next (Peter, 27 September: "carry on with P4"):

  1. P4a, numeric array elements. Done. An element of an integer or float array, as findvar reads it (a bracket straight after the name and its suffix, and not a FUNCTION's), compiles read or assigned. Its array binds to its variable (RB_BARRAY in the bind's type word; a T_PTR array parameter's variable holds the caller's data and dimensions), and a name can never be bound both as an array and as a scalar in one statement. Each index is compiled and then RC_IDX converts and checks it as findvar does straight after evaluating it (a float through FloatToInt32, an integer that does not fit 32 bits "Index out of bounds", below OPTION BASE "Dimensions"), so the errors come in findvar's order. RC_LDEL (the value) and RC_ADEL (the address) then check the count ("Array dimensions") and the bounds and find the element as findvar does, in a flash function (RBElementAt); an assignment makes the address before its right-hand side, as cmd_let's findvar does, and RC_STP stores through it. A call argument that is exactly an element or an array goes to DefinedSubFun, which passes it by reference. The element's own statement still runs as text the first time after the DIM that made its array, as a scalar's does. Test: Bas/bench/rbp4a.py (one to three dimensions, OPTION BASE 1, float indices, every index error and their order, local arrays, array parameters, an element passed by reference, elements in IF, DO and FOR, ERASE and DIM again inside a compiled loop, string arrays left as text). The games compiled against text: Exile -34.5%, Elite -33.0%, PoP -18.9% (were -23.5%, -18.9%, -12.2%); 55-64% of the statements they execute are now compiled. G3 K2 (insertion sort) 2.2x and K4 (findleap) 4.1x: their loops turn on EXIT DO and EXIT SUB, next.
  2. P4b, EXIT and returns. EXIT FOR (cmd_exitfor), EXIT DO and EXIT (cmd_exit), END SUB, EXIT SUB and RETURN (cmd_return, in a flash function) and END FUNCTION and EXIT FUNCTION (cmd_endfun) compile, recognised by their handler so that every spelling is; each goes to its fallback before anything happens when the text path would raise an error ("No FOR loop is in effect", "Nothing to return to"), and a statement with anything after the command stays text for checkend. A single-line IF whose THEN part is not a LET, and has no ELSE, compiles: true goes to the THEN part's record, as cmd_if sends nextstmt there (with an ELSE, cmd_if runs the THEN part itself, so that stays text); THEN IF and THEN linenumber as before. An element of a one-dimensional array in bounds is addressed inline; anything else goes to RBElementAt. Test: Bas/bench/rbp4b.py. G3 K2 8.6x (the gate's 8x); K4 4.4x. These two changes cost 512 bytes of RAM and a level of text-path recursion (rbdepth: 9 as text, 11 compiled), see the note on RAM in P3's list.
  3. P4c, SELECT CASE on a number. SELECT CASE evaluates its selector once and keeps it at the bottom of the VM's stack, then a scan at compile time that raises no errors (RBNextCmd: GetNextCommand without its error) finds the CASEs at its own level, and each is tested as cmd_select tests it, in order: CurrentLinePtr at the CASE's line while its items are evaluated (RC_CLSET) and put back at the end (RC_CGOTO); CASE IS or an operator through doexpr with the selector on the left, whose answer must be an integer; a value converted to the selector's type as evaluate converts it and compared with == (not compare(): a NaN matches nothing, as in cmd_select); a range with >= and <= once both ends are evaluated. The first match goes after its CASE's element, CASE ELSE after its own, no match after END SELECT. A CASE or CASE ELSE reached from the body before it goes after END SELECT (cmd_case), found at compile time; END SELECT (cmd_null) compiles to nothing, so the chain runs on through it. A string selector, a CASE line on the SELECT's own line, and anything the scan cannot finish stay text. The compiler's own state (600 bytes) moved from RAM to temporary memory while it runs, and 1.3 KB more of RAM came back (see the note on RAM in P3's list). Test: Bas/bench/rbp4c.py. The games compiled against text: Exile -38.3%, Elite -37.4%, PoP -22.5% (P4a: -34.5%, -33.0%, -18.9%; compiled, each is 7% faster than at P4a), and the share of the statements they execute that are compiled is now 77%, 68% and 76% (P4a: 64%, 60%, 55%). The anchor (solar_eclipse.bas) 16.07 s as text, 13.39 s compiled: -16.7% (P3's gate: -6.7%). Found on the way: the PC3 lost its firmware in the middle of the chain (00:48, 28 September, just after the interrupt tests' LOADs), the fault recorded before as an erase that latches the wrong address on this board; Route B cannot have written it (with PSRAM it writes no flash). Bas/bench/fwsum.py now checks the firmware in flash against its uf2 after each group of a chain (a PEEK sum per 64 KB block, then per 4 KB sector where one differs), so a repeat names the step and the sectors.
  4. P4d, simple strings. A string scalar (a $ name, one declared AS STRING, or OPTION DEFAULT STRING's) binds to its variable, as an array does, so that its data and its size are read where they are (a BYREF parameter has its caller's), and RC_LDS pushes its data as findvar returns it: getvalue does not copy a variable either. A literal with no backslash, which OPTION ESCAPE cannot change, up to 64 characters, is kept in the code as an MMBasic string and pushed where it is (getvalue copies it into temporary memory). + and the comparisons on two strings call the operator's own function with sarg1 and sarg2, as doexpr does (RC_OPS, in flash with SELECT's float tests: their time is the operator's): + makes its string in temporary memory, so the chain stops after that statement and RunStream's tail frees it, as the text loop's does. LET to a string scalar checks the length against the variable's size and copies, as cmd_let does (RC_STS). A number where a string is wanted, or the other way, an operator strings do not have, and string arrays, functions and call arguments stay text, and so raise the text path's errors. The expressions that take a number (conditions, indices, FOR and SELECT) still take only a number, so a string reaches only LET and the comparisons. 128 bytes of RAM, inside the same 256-byte step. Test: Bas/bench/rbp4d.py (assignment, concatenation, every comparison, the empty string, declared and default strings, locals and BYREF, LENGTH and 255-character errors, type errors, a CONST, single-line IF and DO WHILE, OPTION ESCAPE, long literals, brackets); a string loop 758 ms as text, 144 compiled. The games compiled against text on this build: Exile -37.4%, Elite -34.3%, PoP -19.2%, and compiled Elite and PoP are 4-5% slower than on P4c's build (Exile 0.6%) with a few more statements compiled. A PC-sampled Elite on both builds has the same shape (GetSystemMemory 14.1% and 13.7%, RBRun 9.8% on both); the extra samples are spread over flash code P4d did not touch (DrawRectangle16, cmd_dim, RBCallSub), which is the placement drift between builds seen since P1, and text mode did not move. Goldens under SHADOW: no differences but timings; the anchor -16.7%.
  5. The fallback census after P4d (Bas/bench/rbcensus.py: each game's profiling twin under OPTION COMPILE ON, whose [PERF] counts only the statements that ran as text). Of the statements executed, 77% (Exile), 68% (Elite) and 76% (PoP) are compiled. What runs as text, by command: Elite LET 97k (built-in functions, mostly), END IF 46k, IF 39k, LOCAL 30k, BOX 25k, LINE 12k, MATH 12k, ELSE 11k, ELSEIF 10k; Exile LET 196k, LOCAL 159k, CONTINUE 99k, NEXT 92k, IF 80k, END IF 24k, ELSE 12k; PoP IF 260k, LET 156k, LOCAL 150k, END IF 51k, SELECT CASE 15k, ELSE 12k. GOTO ran 7 times, GOSUB not at all. So the rest of P4 goes where the census points: END IF, ELSE and ELSEIF reached from the block before them, ELSEIF arms, CONTINUE and the NEXTs that still fall back, then LOCAL; GOTO and GOSUB after those. LET and IF with built-in functions are P5's.
  6. P4e, the rest of IF, statements that do nothing, CONTINUE FOR. A multi-line IF whose arms the IF table knows compiles its ELSEIF (and ELSE IF) arms: false goes to the next arm, whose condition is tested with CurrentLinePtr at its line (RC_CLSET), which cmd_if does not put back, so an error on the line after the chain is still reported against the last ELSEIF, as the text path reports it; true goes after the arm's element; past the ELSE or END IF at the end. An arm without THEN, or with text after it, leaves the IF to the text path. ELSE, ELSEIF and ELSE IF reached from the block before them are cmd_else: past the END IF that the IF table gives, looked up exactly as cmd_else looks it up, ELSE after its checkend. A statement whose handler is cmd_null (END IF, ENDIF, END SELECT, DATA, RANDOMIZE, and the END CSUB and END DEFINEFONT markers) compiles to nothing. CONTINUE FOR: cmd_continue sends nextstmt to the loop's NEXT token, in the middle of the NEXT's line, where the map has no key, so the text loop ran every such NEXT (Exile's 99,000 CONTINUEs and 92,000 NEXTs in the census were the same loop); RC_CONTFOR runs that NEXT where it is, sharing RC_NEXT's code, which leaves CurrentLinePtr and TRACE as the text path leaves them. CONTINUE DO, whose LOOP would need another record's code, stays text. RB_MAXCODE went from 96 to 160 words for long ELSEIF chains (compile-time stack only), and RBRun's statement ends now share one copy of their counts, which kept P4e inside the same 256-byte step of RAM. Test: Bas/bench/rbp4e.py (chains with and without ELSE, ELSE IF, nesting, float and string arms, errors in an arm and on the line after the chain, a call in an arm, ELSE with text after it, CONTINUE FOR in a SUB, with NEXT j, i, in a float loop, on the last pass and with no FOR, CONTINUE DO, DATA and RANDOMIZE in a loop, TRACE); an ELSEIF loop 678 ms as text, 91 compiled. The games compiled against text: Exile -39.5%, Elite -36.9%, PoP -26.4% (each 3.5-10% faster compiled than at P4d); compiled shares 82%, 77% and 82%; the anchor -19.9%. The census now has no END IF, ELSE, ELSEIF or NEXT, and Exile's CONTINUEs are down to its 7,000 CONTINUE DOs.
  7. P4f, LOCAL. LOCAL [AS] [INTEGER | FLOAT | STRING] name {, name}, each name a symbol with an optional suffix followed by a comma or the end of the statement, compiles to what cmd_dim does for it, in its order, without its argument splitting and copying: the type from CheckIfTypeSpecified when it runs (OPTION DEFAULT is a run-time setting), then for each name "Invalid here" outside a SUB and findvar with V_LOCAL | V_FIND | V_DIM_VAR | V_DIM_NEW on the name where it is in the program, so every error (already declared, a SUB's name, no type under OPTION DEFAULT NONE) is findvar's own. A comment after the last name, AS per name, brackets, LENGTH or a value stay text: findvar reads the text after a string's name and would take a comment for unexpected text. A structure type in the type's place sends the statement to cmd_dim before anything happens. RBRun paid for it with SELECT's integer tests joining the float ones in flash (RBSel) and EXIT FOR and EXIT DO sharing their tail; it is smaller than before. Test: Bas/bench/rbp4f.py (every form, the text forms, each error, a local hiding a global, recursion); a SUB with a three-name LOCAL 251 ms a 5,000 calls as text, 117 compiled. The anchor is now 95% compiled, but its time hardly moved (7.16 to 7.10 s): a compiled LOCAL still makes each local through findvar, which is most of what LOCAL costs.
  • Gate: the three games are faster and pass their checks, and the census's share of compiled statements, measured with [PERF] counts of fallback records, reaches the G3 figures.
P5splice

The value splice

T_VALUE in getvalue; pure numeric built-ins; then PIXEL, LINE and BOX.

  1. P5a, the pure numeric functions. SIN, COS, TAN, ATN, SQR, EXP, LOG, DEG, RAD, INT, FIX, ABS and SGN in an expression compile, and PI is its constant. For these the splice was not the best tool: each function reads one number and does a line of arithmetic, so a splice would still send every argument through getnumber, evaluate and getvalue to hand it back. Instead each function's work is now a value-level helper in Functions.c (FnSin and the rest, OPTION ANGLE, the integer-degree table on the RP2040 and each function's own errors included), which its fun_ handler calls with the value getnumber reads and which RC_FN calls with the value on the VM's stack: the rule the operators already follow, exact by construction, and the text path unchanged. The argument is converted as the handler reads it (getnumber: an integer becomes a float; ABS and SGN evaluate with no type and keep it). An argument that does not end at the function's closing bracket (a second argument, which the text path quietly ignores) leaves the statement to the text path. The splice stays the plan for commands and for functions that read text. Test: Bas/bench/rbp5a.py (every function on floats and integers, OPTION ANGLE DEGREES, the functions' errors, ABS and SGN types, a second argument, a string argument, nesting, a user FUNCTION inside SIN, a line of the anchor); a SIN, COS and SQR loop 678 ms as text, 131 compiled. The anchor: 16.0 s as text, 7.2 s compiled (-55%; P4e -20%), 91% of its statements compiled; what remains is mostly LOCAL (P4f), the display's scrolling and the background work. The games compiled against text: Elite -48.5% (-18% on P4e compiled; 89% of its statements compiled, 77% before), Exile -39.2%, PoP -28.4% (their remaining text statements are other functions); the goldens under SHADOW clean.
  2. P5b, the value splice: BOX, LINE and PIXEL. A command whose arguments are all numeric expressions the compiler takes compiles to its arguments evaluated onto the VM's stack and then RC_SPLICE, which calls the command's own handler as RBExec would, but with cmdline at a copy of its arguments kept in the code: T_VALUE (0x10, a byte tokenise never leaves in program text) and a letter for each value ('A'+i an integer, 'a'+i a float, in the order they are on the stack), the commas and empty arguments as they were. getvalue meets a T_VALUE only at the end of its chain of cases, where it would otherwise raise a syntax error, and reads the value through RBSpliceValue. The handler does not know the difference: getcsargs splits the copy as it split the text, and getargaddress takes a spliced argument for a literal, which is the scalar path. A user FUNCTION in an argument leaves the command to the text path, because getargaddress evaluates an argument that is a variable a second time; so do LINE's PLOT, GRAPH and AA forms and array arguments. LINE and PIXEL read OPTION LEGACY's syntax when it is on, so RC_GUARD first sends them to the fallback before anything happens when CMM1 is set. Test: Bas/bench/rbp5b.py (what each form draws, read back with Pixel(), the range and argument-count errors, OPTION LEGACY both ways, LINE AA, arrays, a FUNCTION argument); a BOX and LINE loop 194 ms as text, 114 compiled, the drawing now most of it. Elite: 23.4 ms a frame as text, 11.6 compiled (-50.5%), 95.5% of its statements compiled.
  3. The main program's bind caches survive calls (option C). Every local made or freed bumps SymBindGen, so after any call every compiled record re-bound. SymBindGenG now counts only what can change a global's binding (every bump of SymBindGen but a local made or freed). At level 0 no local can exist, so a record there stamps its cache with SymBindGenG, bit 31 set, and keeps it across calls; in a SUB, where a LOCAL made part way through a call can hide a global a record has already bound, the stamp stays the whole SymBindGen with bit 31 clear, so a stamp made at one level never matches at the other. Test: Bas/bench/rbcache.py (the main program around calls, a LOCAL hiding a global part way through a loop, a GOSUB from a SUB into the main program's code, ERASE and DIM and OPTION DEFAULT between calls); a main loop around SUB calls 90.8 ms before, 77.5 ms after. Elite 1.8% faster compiled; the anchor, whose work is inside SUBs, unchanged. The locals' own share waits for the frames (P6). An ELSEIF loop (rbp4e) went from 91 to 104 ms between P5a and P5b with nothing in its path changed: RBRun's layout moved, and the games did not.
  4. P5c, INC and the string functions (Peter, 29 September: "INC, LEN and the basic string functions"). INC var [, value] compiles as cmd_inc does it: the variable first (its bind, or an element's address and then, RC_DUPLD, its value), then the value read as getinteger, getnumber or getstring reads it for the variable's type, added plainly (cmd_inc makes no overflow check: RC_ADDI, which is already a plain add, and RC_INCF for floats, where RC_ADDF's infinity check must not happen) or, for a string, concatenated after cmd_inc's length check (RC_INCS, sharing RBStoreStr with LET's store). INC with no value on a string, a CONST, a structure member and a string array element stay text (cmd_inc's errors and forms). No FUNCTION call in the value: C leaves open whether cmd_inc reads the variable before or after the call (P5d found GCC reads it before, and allows calls). The string functions LEN, ASC, CHR$, MID$, INSTR, STR$, SPACE$, TRIM$, LEFT$, RIGHT$, UCASE$, LCASE$, HEX$, OCT$ and BIN$ compile through the value splice, now for functions (RC_FSPLICE): the arguments' values onto the VM's stack, then the function's own handler called as getvalue calls it, ep at a copy of its argument text in which each value is T_VALUE and a letter ('0'+i now a string, which getvalue's T_VALUE case returns as getvalue returns a string variable, uncopied), its answer from fret, iret or sret replacing the values. The copy is made into a static buffer at each call, since a handler may write into its argument text as it may into getvalue's copy. LEFT$, RIGHT$, UCASE$ and LCASE$ are tokenise's SChange$ with its selector letter first, which the splice passes as text; HEX$, OCT$ and BIN$ are base$ with the base as a literal. Only functions of one result type (not VAL, whose type is its string's); not the forms that read an argument as a name or keyword (INSTR's pattern forms, whose last argument is a variable; TRIM$'s third argument, which may be a keyword); not STRING$, whose error names its argument's text, which would be the splice's; no FUNCTION call in the arguments, which the handler would read in its own order, and no empty argument. RBRun's RAM stayed in its 256-byte step with RC_INCS sharing L_STS's call. Test: Bas/bench/rbp5c.py (INC on integers, floats and strings, scalars and one- and two-dimensional elements, in a SUB, wrapping and overflow, its four errors; every function with its edge cases and errors, nested, in IF, DO and SELECT CASE, a user FUNCTION argument left to text), each OFF, ON and SHADOW alike, with counts that prove the loops compiled; rbp2b's text-stays program now uses VAL and CINT. A string loop 318 ms as text, 184 compiled (-42%). PETSCII Robots: 61% of its statements compiled (49% before), text INC 40,874 to 1,439 and IF 662,195 to 15,290 in a minute; its update time is unchanged (23.2 ms), since what remains there is PEEK and BLIT MEMORY (the tile loop, about 10 ms of a frame), STATIC in SUBs, LOCAL with a value, MIN, SPRITE MEMORY, and TIMER.
  5. P5d, programs as they are written (Peter, 29 September: "the objective is to avoid users having to change their programs to get the benefits ... they have no way of knowing what did or didn't compile"). Found with Peter's own test programs. Untyped FUNCTIONs (no suffix, no AS) compile as OPTION DEFAULT's type in text order, float without one; they had all been text, and a program built on one (the landscape demo's 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.
  6. P5d, second round, before V7.0.00b1 goes out (Peter, 29 September: "do 1 to 4 before I release"). Found writing the user update. A statement stayed text until every variable it names had been looked up once: a record binds all its variables at its start, a variable was bound only when a lookup found it existing (findvar's found path), and one made by DIM or by its first assignment was not, so 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.
  • Gate: goldens and shadow mode clean; the anchor (SIN/COS-heavy) and Elite's frame improve.
P6frames

Frames for a SUB's locals

Peter's idea, 28 September: the locals a SUB can have are known before it is entered, so lay them out once and take them in one go.

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.

  • The layout (PrepareProgram, every run, text or compiled): per SUB or FUNCTION, its names in order with each one's kind (scalar, string, array, structure, STATIC pointer) and declared type, or "OPTION DEFAULT when it runs". Route B's per-unit survey already reads most of this.
  • Entry (DefinedSubFun, and RC_CALL / RC_FCALL): push a frame of N entries, parameters and the FUNCTION's name made as today, every LOCAL and STATIC entry marked "not yet declared".
  • LOCAL and STATIC mark their entries declared, allocate a string's or an array's memory as today (sizes can be known only then: 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.
  • findvar for a symbol inside a SUB: the SUB's map gives the offset; a declared entry is the local, otherwise the global path. A name as text (EXECUTE, a command that spells its line out) searches the current frame's few names. A LOCAL made through EXECUTE, which nothing foresaw, is refused.
  • Exit and unwinding (END SUB, RETURN, EXIT, an error, ON ERROR, ClearVars(level)): free what declared strings and arrays hold and pop the frame. Nothing is hashed, so there is nothing to tombstone.
  • Compiled code binds a local as frame base + offset, known at compile time: no binding at run time for locals, and with option C's globals-only generation the globals' binds survive calls too.
  • Also to follow: CHAIN and SAVE/LOAD CONTEXT save the frame stack; OPTION LOCAL VARIABLES sizes it; "Too many local variables" becomes a frame that does not fit; recursion simply stacks frames (SymL's shadow chain is no longer needed for frame locals).

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:

  • A level is more than a SUB call. GOSUB, an interrupt (which raises it twice for a SUB handler), CallExecuteProgram and MM.USER_RECTANGLE / MM.USER_BITMAP raise g_LocalIndex too, and a GOSUB routine may have LOCALs of its own. So the frame belongs to the level: each level's locals are appended above its base as they are made, found by symbol through SymL as now or, for a name as text, by a search of that frame, and dropped together. The per-SUB layout then becomes what it should be, an optimisation on top (a call's parameters and LOCALs made in one go, fixed offsets for Route B), not something correctness hangs on.
  • A live local must never move: BYREF and array parameters point into the caller's slots, and STATIC aliases, the FOR and DO stacks, SymL and Route B's bind cache all hold slot numbers or addresses. Frames are strictly last in, first out, so they never do.
  • Existing loose ends a frame stack must settle, to be reported rather than changed quietly: the ON ERROR SKIP path and the error-to-prompt path lower g_LocalIndex without freeing the deeper locals; ClearVars assumes newest-first order; Symbols.c declares 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.)
  • Also to follow: LIST COLLISIONS and MEMORY assume the hashed layout (MEMORY counts blocked markers as variables); SAVE/LOAD CONTEXT copies the whole table and the creation list; the CSUB interface exports &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.

Decisions (Peter, 26 September 2026)
  1. Compile at RUN when the image has changed, stamped by a CRC of the image: yes.
  2. Stream slots while developing: RAM slot 4 on the PC3 and flash slot 2 on the RP2040, refused to the user while OPTION COMPILE is on: yes.
  3. OPTION COMPILE ON | OFF | SHADOW as a RAM-only development switch, off by default until the P3 gate: yes.
  4. Libraries are compiled too, not left as text. Consequences: the stamp's CRC and the statement map cover the library image as well as the program, so the executor stays in compiled code across a call into the library; LIBRARY SAVE gains symbols (the library table the symbol format already reserves, found through SymTabLib), added in P2 so library names bind like the program's; and library SUBs leave the list of DefinedSubFun fallbacks in P3.
  5. Bugs found while preparing this: all fixed on main (the comment fast path e49d3ff, MAX/MIN 50c9aa0, ABS/SGN 16d0413) and merged into development with V6.04.00RC2 (ac6ca56).
Decisions (Peter, 27 September 2026)
  1. Route B is RP2350-only. The RP2040 has no 4 KB page of RAM below the heap for the executor, and from flash it runs slower than text. Removing Stream.c from the RP2040 build also returned its flash margin (8.4 to 12.4 KB) and RAM margin (212 to 820 bytes).
  2. The stream gets a PSRAM region of its own instead of RAM slot 4: agreed. For boards without PSRAM Peter asked whether a new flash area is the equivalent. It is; it would sit after the program area, cost the A: drive MAX_PROG_SIZE on every RP2350 variant (the layout is fixed at build time, while PSRAM is a run-time option), and reformat A: once on upgrade. Agreed: flash slot 2 for the moment; a hidden flash slot of its own is added later, with the release that makes Route B a user feature.
  3. OPTION LOCAL VARIABLES is reset at RUN, NEW and LOAD (ClearRuntime(true)), not on CHAIN, whose saved variables were placed with the split in force: agreed and done on development (e670e0a) and on main (336a17d).

Found on the way

Route B reproduces each of these exactly, because it calls the same helpers. Fixing one changes both paths at once.