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:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user