Today every possible variable has a full 56- or 64-byte entry in a static table, used or not. That is 26.9 KB of the RP2040 VGA's RAM, more than a quarter of its 100 KB heap, and 47-49 KB on the RP2350 builds. This document reviews Peter's proposal to keep only a small slot per variable in the table and to give each variable its full record from the heap when it is created. It recommends doing it, with four changes, and sets out the design, what it costs, what it would break, and how to build it in stages.
Branch development at 0b7528f (V7.0.00b2) · evidence: two read-only code surveys for this review (the table's internals; everything outside the interpreter that depends on its layout), an offline count of the names in six real programs and the 112-program test corpus, and the 2026-09-30 heap investigations of la_24_2.bas and Bad Apple on the RP2040 VGA. No code has been changed.
OPTION SYMBOLS OFF.PEEK(VARHEADER) offsets (Bad Apple uses Peek(varheader y1())+36) and the layout CSUBs are given, to save 24 bytes per variable.PEEK/POKE VARTBL, and SAVE CONTEXT images written by older firmware. See Decisions.g_vartbl is a static array of MAXVARS entries in BSS (MMBasic.c:121), separate from the heap. Each entry is a struct s_vartbl (MMBasic.h:279-299):
| Bytes | Field | Notes |
|---|---|---|
| 0-31 | name[32] | Upper case, no suffix, not NUL-terminated at 32 characters |
| 32-35 | type, level, size, namelen | type uses all 8 bits (T_NBR ... T_STRUCT); namelen now holds flags only |
| 36-47 / 36-55 | dimtbl | 6 shorts on the RP2040, 5 ints on the RP2350. A short string (up to 9 / 15 characters) is stored here, in dimtbl[1..] |
| 48-55 / 56-63 | val | 8 bytes, 8-aligned: the number, or a pointer to string, array or struct data |
| Build | Globals + locals | Entry | Table | g_hashlist |
|---|---|---|---|---|
| RP2040 PICO, PICOUSB, PICOMIN | 256 + 240 | 56 | 27,776 | 1,856 |
| RP2040 VGA, VGAUSB | 240 + 240 | 56 | 26,880 | 1,792 |
| WebMite RP2040 | 240 + 192 | 56 | 24,192 | 1,600 |
| RP2350 VGA, VGAUSB, HDMI, HDMIUSB, HDMIBTH, HDMIWEB | 544 + 192 | 64 | 47,104 | 2,816 |
| RP2350 PICO, PICOUSB, BT, BTH, WebMite RP2350 | 576 + 192 | 64 | 49,152 | 2,944 |
probe_global_slot, MMBasic.c:5017-5080). ERASE frees a global's slot outright if the next slot is empty. If the next slot is in use, a later variable may have probed through this one to reach its own, so the slot is marked T_BLOCKED (name[0]='~') instead. A lookup walks on past a blocked slot, so the chain stays intact; if the name is not found further along, the new variable takes the blocked slot it passed (MMBasic.c:5038-5075, 7187-7199). A blocked slot is therefore both a link in a chain and free for reuse.g_localtop, g_hashlist records its slot and level, and a SUB's exit drops its locals (MMBasic.c:5433, 5807-5815, 6974-7016).SymG, SymL, int16) hold slot numbers, so after the first lookup a name goes straight to its slot.val.s = &dimtbl[1]). The free paths skip them only because the table is outside the heap's address range: FreeMemorySafe ignores addresses it does not own.Distinct names in each program, counted offline: everything that is not a keyword (the command and function tables plus every sub-keyword the firmware checks for). "Globals" is an upper bound, since it includes CONSTs, which also take an entry, and anything the counter could not prove local. For Prince of Persia it must really be under 544, its build's limit.
| Program | Names | SUB/FUNCTION | Only local | Globals ≤ | Most locals in one SUB | Names > 8 chars | Share first 3 chars | Share length + hash16 |
|---|---|---|---|---|---|---|---|---|
| la_24_2 (logic analyser, RP2040 VGA) | 110 | 23 | 39 | 61 | 13 | 10 of 88 | 32% | 0 |
| Bad Apple (RP2040 VGA) | 43 | 2 | 3 | 39 | 2 | 14 of 41 | 35% | 0 |
| Exile | 347 | 59 | 66 | 175 | 8 | 10 of 232 | 49% | 0 |
| Arkanoid | 419 | 60 | 74 | 281 | 12 | 114 of 347 | 58% | 0 |
| Elite | 878 | 260 | 147 | 496 | 15 | 106 of 618 | 58% | 0 |
| Prince of Persia | 984 | 252 | 111 | 640 | 11 | 223 of 732 | 64% | 0 |
| Test corpus, 112 programs | globals: median 2, 90th percentile 9, largest 290 | 156 of 850 | ||||||
Two conclusions shape the design. Programs written for the RP2040 use a small fraction of its table, which is where the gain is. And the large games come close to the RP2350's limit, so the design must not cost them memory.
Scripts: scratchpad varcount.py and prefix.py (2026-10-01). Sources: B:/la_24_2.bas, b:/badapple/badtweaked.bas, Bas/arkanoid.bas, Bas/bench/phase1/games/{exile/gbxphys, elite/gbelite, pop/gbpop}.bas, and the mmb2c test corpus.
The slot table replaces the entries for globals. 8 bytes per slot: a state byte, the name's length, a 16-bit hash, and the record's address. 240 global slots take 1,920 bytes instead of 13,440.
The proposal puts the type in the slot. The design keeps it in the record only: the type and its flags are changed after creation (T_PTR on parameters and STATICs, the marker DIM sets while it allocates, ERASE), and read in many places, so a copy in the slot would have to follow every change. The slot needs only free, in use or blocked.
Seven pages hold exactly 32 RP2040 records, as proposed. On the RP2350 seven pages hold 28; eight hold 32. Using 32 on both chips makes a local's record a shift and a mask from its slot number.
Chunks must come from the SRAM heap only. GetMemory can fall back to PSRAM, which would slow every variable access, and SAVE CONTEXT does not capture PSRAM contents.
The aim, settling a probe without touching the record, is right. Three letters do not do it: 32-64% of names share their first three letters with another, and every STATIC's hidden global starts with its SUB's name. The FNV hash findvar already computes supplies a 16-bit check for free, and with the length it separated every name in all six programs. The record is still read once to confirm a match.
A free list costs almost nothing: an erased record's first word links to the next free one, and creation takes from the list before the chunk. The slot itself is freed or marked blocked exactly as today, so chains stay intact and the slot is reused by the next new variable that probes through it.
It would save 24 bytes per variable (records of 32 / 40 bytes). But 4-34% of names in the programs measured, and 18% in the test corpus, are longer than 8 characters and would need a second allocation; every name comparison and print would need a helper; and the fields after the name would move, which breaks PEEK(VARHEADER) offsets (Bad Apple's +36), the layout CSUBs are given, and the Structures manual. With items 1-4 even full tables break even, so the saving is not needed. Revisit only as a deliberate compatibility break.
struct s_gslot { // one per global slot (MAXGLOBALVARS of them)
uint8_t state; // GS_FREE 0, GS_USED 1, GS_BLOCKED 2 (T_BLOCKED today)
uint8_t namelen; // 1..32
uint16_t hash16; // top 16 bits of the FNV-1a hash
struct s_vartbl *rec; // the record; NULL when free
};
struct s_vartbl // unchanged: 56 bytes on the RP2040, 64 on the RP2350
#define VREC_PER_CHUNK 32 // 7 pages on the RP2040, 8 on the RP2350
static struct s_gslot g_gslot[MAXGLOBALVARS];
static struct s_vartbl *g_lchunk[(MAXLOCALLIST + 31) / 32]; // local record chunks, kept for the run
static struct s_vartbl *g_gfree; // erased global records (LIFO)
static struct s_vartbl *g_gnext, *g_gend; // next unused record in the current chunk
The index space stays as it is: slots below maxlocalvars are locals, the rest globals. So g_VarIndex, SymG, SymL, SymLShadow, SymLCanon, g_hashlist and the FOR/DO stacks keep their meaning. One accessor maps an index to its record:
#define VREC(i) ((i) < maxlocalvars ? &g_lchunk[(i) >> 5][(i) & 31] \
: g_gslot[(i) - maxlocalvars].rec)
The ~350 uses of the variable findvar has just found take VREC(g_VarIndex). A cached g_VarRec, set by findvar, was tried in phase 1 and dropped: solar_eclipse ran 0.8% slower on the RP2040 VGA and 1.6% slower on the PC3. Whether to cache the record is decided in phase 2, measured against the real VREC.
hash % maxglobalvars, and probe. A free slot ends the search; a blocked slot is passed but remembered, and a new variable takes it if the name is not found further along, as today; a used slot whose namelen and hash16 match is confirmed against rec->name. The probe reads 8-byte slots instead of 32-byte names.A local's record is g_lchunk[slot >> 5][slot & 31]. When the stack first reaches a new multiple of 32, a chunk is allocated; chunks are kept until the run ends, so a program allocates them only the first time its call depth reaches a new high. Making and dropping locals is otherwise unchanged. OPTION LOCAL VARIABLES n becomes a cap on the local stack alone: raising it no longer takes slots from the globals.
RUN allocates the first chunk of globals and the first chunk of locals before the program runs (3.5 KB on the RP2040, 4 KB on the RP2350). A program with up to 32 globals and 32 live locals then never allocates for variables during the run, and so can never fail to make one for lack of heap. Larger programs allocate further chunks mostly while declaring their globals, near the top of the program, before their large buffers.
&dimtbl[1] as heap memory. Done in phase 1: VAR_INLINE_STR(rec) (val.s == &rec->dimtbl[1]) guards ClearVars (locals, and the whole table), erase, a SUB's string argument and a FUNCTION's string result in MMBasic.c, and STATIC and CONST in Commands.c. The first survey missed STATIC and CONST. MEMORY already made the same check and now uses the macro. A struct parameter needs no guard: its data is always heap memory.VREC(i) instead of &g_vartbl[i]. Its cache of record pointers stays valid because records never move.HEAP_MEMORY_SIZE in whole 4 KB steps. Where a build has MAX_PROG_SIZE == HEAP_MEMORY_SIZE, MAX_PROG_SIZE must first be set to its current value, or the flash layout moves and A: is wiped on the update.| Build | Table today | Slots + chunk pointers | Freed | Heap, 4 KB steps | Left to margin |
|---|---|---|---|---|---|
| RP2040 PICO, PICOUSB, PICOMIN | 27,776 | 2,108 | 25,668 | +24 KB | 1,092 |
| RP2040 VGA, VGAUSB | 26,880 | 1,976 | 24,904 | +24 KB | 328 |
| WebMite RP2040 | 24,192 | 1,972 | 22,220 | +20 KB | 1,740 |
| RP2350 VGA and HDMI builds | 47,104 | 4,440 | 42,664 | +40 KB | 1,704 |
| RP2350 PICO builds, WebMite RP2350 | 49,152 | 4,700 | 44,452 | +40 KB | 3,492 |
The builds' fit checks have the final word, including the newlib C-heap rule on the USB builds (at least 4 KB between the end of BSS and the stack).
| Program and build | Chunks (globals + locals) | Records | Heap compared with today |
|---|---|---|---|
| la_24_2, RP2040 VGA | 2 + 1 | 5,376 | +18.8 KB |
| Bad Apple, RP2040 VGA | 2 + 1 | 5,376 | +18.8 KB |
| Any program with ≤ 32 globals and ≤ 32 live locals, RP2040 VGA | 1 + 1 | 3,584 | +20.5 KB |
| A full RP2040 VGA table: 239 globals, 32 live locals | 8 + 1 | 16,128 | +8.3 KB |
| Worst case, RP2040 VGA: 239 globals and 239 live locals at once | 8 + 8 | 28,672 | −4.0 KB |
| Exile, RP2350 | 6 + 2 | 16,384 | +24.0 KB |
| Elite, RP2350 | 16 + 2 | 36,864 | +4.0 KB |
| Prince of Persia, RP2350 | 17 + 2 | 38,912 | +2.0 KB |
| Worst case, RP2350 HDMI: 543 globals and 191 live locals at once | 17 + 6 | 47,104 | −6.0 KB |
The games' counts are upper bounds, so their real gains are a little larger. The two worst cases need every local slot live at the same moment, which no measured program comes near: the largest single SUB declares 15.
The symbol tables (3-5 KB) and the per-SUB parameter layouts (1 KB plus a page per SUB called) are unaffected. With 19 KB more heap, la_24_2 and Bad Apple would run with them, and OPTION SYMBOLS OFF would become a rarity.
| What | Effect | |
|---|---|---|
PEEK(VARADDR v), PEEK(VAR v, off), POKE VAR | Unchanged. The value stays inside its record, 8-aligned, and never moves while the variable exists. | same |
PEEK(VARHEADER v) and offsets from it | Unchanged: it returns the record, whose layout is identical. Bad Apple's +36 keeps working. | same |
| VAR SAVE / VAR RESTORE | Unchanged: the flash format is keyed by name and type, not by entry. | same |
| Pointers held by drivers (IR, KEYPAD, PLAY STREAM, TCP STREAM), the FOR and DO stacks, Route B | Unchanged: records never move. | same |
MM.INFO(VARCNT), LIST VARIABLES, LIST COLLISIONS | Same output. Collisions are still those of the global slots. | same |
OPTION LOCAL VARIABLES n | Caps the local stack without taking slots from the globals. | better |
MEMORY, MM.INFO(HEAP) | The heap is larger, and falls as variables are created. Figures differ from today. | differs |
| Creating a variable | Can fail with "Not enough memory" once the heap is full, beyond the seeded chunks. Today only the slot limits apply. | new |
PEEK(VARTBL, off), POKE VARTBL | There is no longer one array. Can be emulated: off / sizeof(entry) is the index, the remainder the byte in its record. A free slot reads as zeros. | emulate |
CSUB call table 0x60 (&g_vartbl) and 0x64 (&g_varcnt), the layout in PicoCFunctions.h | A CSUB that walks g_vartbl breaks. None is known: mmb2csub reaches globals through VARADDR, which is unaffected. The offsets are an append-only ABI, so 0x60 must keep some meaning. | breaks |
SAVE CONTEXT images (/.vars on A:, the PSRAM reserve) from older firmware | Refused, once the header is added. Today they would be restored blindly into the wrong layout. | refused |
Documentation to update: help.txt and the manual (MEMORY, OPTION LOCAL VARIABLES, PEEK/POKE VARTBL, VARHEADER), MMBasic_Structures_Manual.md:2589-2610, armcfgen.md section 1.5 and its 0x60/0x64 table, PicoCFunctions.h:395-416, and the planned mmb2csub phase 3 (MMBasic_To_CSUB_Plan.md:296-298), which assumed it could walk the table.
OPTION SYMBOLS OFF, the command prompt) get faster. A probe reads 8-byte slots and touches a record only when the length and hash16 match, where today it compares names in 56- or 64-byte entries.g_vartbl, and about 155 call findvar. Most uses follow findvar and take VREC(g_VarIndex); the walkers (ClearVars, erase, LIST, MEMORY, SAVE CONTEXT, Route B binds, the symbol layer) need care. This is why phase 1 changes no behaviour.Every indexed use of g_vartbl goes through VREC(i), defined for now as &g_vartbl[i]: 664 sites in 22 files, and every object the same size as before on both builds. The short-string guard at every free site. The SAVE CONTEXT header is covered by the magic-number decision. A cached g_VarRec was tried and moved to phase 2 (see Recommended design).
VREC(i) reads g_slotrec[i], one record pointer per variable number, so a record is one load away whether it is a local's or a global's. Measured on solar_eclipse before anything moved: the RP2040 about 1% slower (one more cycle per record access; accepted for the memory), the RP2350 no slower. A global's record comes from an SRAM-only chunk of 32, the first seeded at RUN. An erased global's record goes on a free list, linked through its name, which no background writer touches. A free or blocked slot points at a shared placeholder record, so every probe and walker reads a slot as before. CLEAR keeps the chunks and hands their records out again, as IR, KEYPAD, PLAY STREAM and TCP STREAM may still be writing to a global's value; the chunks go when the heap is wiped. g_vartbl holds only the locals, so OPTION LOCAL VARIABLES is capped at MAXLOCALVARS until phase 3. PEEK/POKE VARTBL stop with an error naming PEEK(VARHEADER); call-table 0x60 is g_slotrec. SAVE CONTEXT keeps g_vartbl, g_slotrec and the chunk state; the magic numbers change once, in the commit that finishes the redesign, as each phase changes the format again and every change wipes a board's options and A:.
A 4-byte entry per global slot (state, length, hash16), so a probe reads a 32-byte name only to confirm a match.
Each chunk of 32 local slots takes its records from the heap the first time the local stack reaches it, fills those 32 pointers, and is kept for the run. g_vartbl goes, and the cap on OPTION LOCAL VARIABLES with it.
Per build, in whole 4 KB steps, with MAX_PROG_SIZE pinned so the flash layout and A: are unchanged.
Raise MAXGLOBALVARS where useful, since a slot costs 8 bytes; bind globals to record pointers rather than slot numbers.
compact-vars from development.PicoCFunctions.h. 0x64 (g_varcnt) stays.PEEK/POKE VARTBL are removed: they were unusable with the hashed table anyway./.vars) is wiped on the first boot, as for any layout change.g_VarRec in phase 1: it cost 0.8% on solar_eclipse (VGA). It is reconsidered in phase 2.g_slotrec) rather than the slot's own pointer: VREC without a branch. The RP2040's 1% is accepted for the memory.OPTION LOCAL VARIABLES is capped at its default on this branch.From the two surveys. Fixed on development before this branch was made:
g_varcnt: fixed in ee1abbf.Still as found:
/.vars written by another firmware is restored blindly. Handled for this work by regenerating the magic numbers (see Decisions).PicoCFunctions.h calls byte 35 dummy and omits T_STRUCT. Memory.c's memory-map comment (1860-1892) still describes a growing variable table.