Stop a scalar created inside a scope costing value-pool slots

A scalar now lives in the variable record (`akbasic_Variable::inlinevalue`)
rather than drawing from the value pool, so a `GOSUB` local, a `FOR` counter and
a `DEF` parameter cost nothing at all.

The pool is a bump allocator with no free, and its comment justified that with
"nothing in BASIC destroys a variable". Scope exit does: it marks the variable
slot unused, `new_variable()` memsets the slot it hands back -- clearing
`values` -- and `variable_init()` therefore took *fresh* slots for a variable
whose old ones were still counted. Every scope that created a local leaked, with
no diagnostic until the pool ran dry on whichever line happened to be unlucky.

Six thousand `GOSUB`s creating one local used to die on the 4091st at `LOC# = 1`
with "Array of 1 elements does not fit in the 0 remaining value slots". They now
run. A `DEF` called eight thousand times used to die between the four and five
thousandth -- the leaking slot was the call scope's parameter, which is a scalar
-- and both forms now run. A game creating one name per tick was dead in half a
minute; the Breakout in examples/ was, after twenty-five seconds.

**A `@` name is the one exclusion, and it is the whole of it.** A structure or a
pointer to one keeps pool storage, because a pointer into a record outlives the
scope that DIMmed it -- docs/16-structures.md says nothing is reclaimed and
`prev_environment()` relies on it. The name suffix is the right test rather than
`structtype`, which the DIM path sets *after* calling `variable_init()`. A local
array therefore still leaks, deliberately, and is now the narrow rule the
tutorial teaches.

`SWAP` needed the other half: it copies whole variable records, so the `values`
pointer that came over named the other variable's inline slot -- which by then
held this variable's own old value -- and SWAP silently did nothing. Caught by
tests/language/housekeeping/verbs.bas, which is the golden corpus earning its
keep.

tests/value_pool.c is the new coverage. It asserts the mechanism as well as the
consequence: a later change that moved arrays inline too would pass every
behavioural case and quietly break the pointer guarantee. The sharpest case
takes the pool's whole 4096 slots in four arrays after two hundred scope
entries, so one leaked slot has nowhere to go.

Chapter 17 Step 3 taught "declare every name at the top" and no longer needs to.
It now teaches what is still true -- a name first seen inside a subroutine dies
at RETURN, so a routine cannot answer its caller through one -- and its
demonstration is the array case, which still fails.

TODO.md section 6 item 30 and section 9 item 1, both struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:47:49 -04:00
parent caebc1a174
commit 05f241aca1
10 changed files with 419 additions and 97 deletions

View File

@@ -91,65 +91,13 @@ A brick is four cells wide, so ten of them are forty cells, and `BLEFT#` centres
whatever `COLS#` turned out to be. Integer division truncates here, which is exactly what
a cell index wants.
## Step 3: Create every name before the game starts
## Step 3: Declare the names a subroutine has to answer through
This is the rule that decides whether your game runs for four minutes or for ever, and it
is worth getting straight before you write a single subroutine.
A `GOSUB` gets its own scope. Assignment walks up the chain and finds an outer name, but a
name **first seen** inside a subroutine is created in that subroutine and dies at
`RETURN` — so a routine cannot answer its caller through a name it invented itself.
**A name that is created costs value-pool slots that are never handed back.** The pool is
a bump allocator with 4096 slots for the life of the run. Scope exit *does* destroy
variables — a `GOSUB`'s locals go when it returns — but their slots are not reclaimed, so
recreating the same local on the next call takes fresh ones.
Here is the whole defect on one screen:
```basic
X# = 0
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
```
```output
? 8 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots
```
Six thousand calls, one new name each, and it dies on the four-thousand-and-somethingth
— naming the line that was unlucky rather than the line that was wrong. A game loop that
creates one name per tick is dead in half a minute. This one was, and it took twenty-five
seconds to do it.
The fix is one line, moved:
```basic
X# = 0
LOC# = 0
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
```
```output
OK 6000
```
So **declare everything at the top** — variables, scratch temporaries, loop counters, all
of it — and after that the program only ever stores into names that already exist:
That is why the game declares this block before anything calls anything:
```basic norun
DIM BR#(60)
@@ -161,23 +109,66 @@ BX# = 0
BY# = 0
HIT# = 0
BI# = 0
I# = 0
R# = 0
C# = 0
KI# = 0
```
`I#`, `R#`, `C#` and `KI#` are in that list because a `FOR` counter is a name like any
other: name one that does not exist yet and every entry to the loop spends slots.
`HIT#` and `BI#` are how `HITTEST` (Step 8) answers the routine that called it. Declared
out here they are one variable the whole program shares; left to `HITTEST`, they would be
two that vanish at `RETURN` and the caller would read zeros for ever, with no diagnostic
of any kind. [Chapter 4](04-control-flow.md#goto-and-gosub) has the scope rules.
The same block does a second job. A `GOSUB` gets its own scope, and assignment walks up
the chain to find an outer name — but a name *first seen* inside a subroutine is created
in that subroutine and dies at `RETURN`. `HIT#` and `BI#` are how `HITTEST` answers its
caller, so they have to exist before anybody calls it. See
[Chapter 4](04-control-flow.md#goto-and-gosub) for the scope rules.
**Creating a scalar inside a scope is free**, so nothing else has to be declared up front:
This is filed as `TODO.md` §6 item 30. Until it is fixed, the declaration block is not a
style preference — it is the price of a program that runs.
```basic
X# = 0
FOR T# = 1 TO 20000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
```
```output
OK 20000
```
Twenty thousand calls, each creating a local. **This did not use to work.** A scalar drew
storage from the same 4096-slot pool an array does, scope exit gave back the variable but
not its storage, and a program creating one name per tick died in about half a minute —
naming whichever line was unlucky rather than the line that was wrong. It is what killed
the first version of this game after twenty-five seconds. `TODO.md` §6 item 30 has the
history; `tests/value_pool.c` is what keeps it fixed.
**An array is different, and still costs slots for good.** `DIM` inside a subroutine takes
pool storage that never comes back, because a pointer into a record can outlive the scope
that declared it — see [Chapter 16](16-structures.md#limits):
```basic
X# = 0
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
DIM LOC#(4)
LOC#(0) = 1
X# = X# + LOC#(0)
RETURN
```
```output
? 8 : RUNTIME ERROR Array of 4 elements does not fit in the 0 remaining value slots
```
So the rule that survives is narrow: **`DIM` at the top, not in a loop.** Which is where
you would have put it anyway.
## Step 4: Build the wall, and write a row whole
@@ -717,8 +708,9 @@ every bounce, so it plays like something with a hand on it and occasionally miss
demo that tracks perfectly never loses a ball and therefore never tests losing one.
Leave it running for two minutes and watch the score climb: that exercises the ball, the
bricks, the level change, the speed-up and — most usefully — the value pool from Step 3,
which is the failure that only shows up after thousands of ticks.
bricks, the level change and the speed-up and it is the only thing that exercises them
*together*, for long enough. Every defect this game found took thousands of ticks to show
itself, and a green test suite proved nothing about any of them.
The machine does not get on the scoreboard. Attract mode keeps score so the wall behaves,
but only a player's score is ever the high one:
@@ -739,7 +731,7 @@ game against:
| Rule | Because | Where |
|---|---|---|
| Create every name at the top, loop counters included | A name created inside a scope costs pool slots for good; 4096 ends the run | Step 3 |
| `DIM` at the top, never in a loop | An array created inside a scope costs pool slots for good; 4096 ends the run. A scalar is free | Step 3 |
| Build a text row whole and write it from column 0 | `CHAR` terminates the row where it stops | Step 4 |
| Tell rows apart by shape, not colour | `CHAR` ignores its colour argument | Step 4 |
| Compute a `MOVSPR` coordinate into a variable first | A leading sign means *move by*, not *move to* | Step 5 |