Add two Breakout examples and the tutorials that build them
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m10s
akbasic CI Build / sanitizers (push) Failing after 4m5s
akbasic CI Build / coverage (push) Failing after 3m29s
akbasic CI Build / akgl_build (push) Failing after 21s
akbasic CI Build / mutation_test (push) Failing after 3m19s

Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.

`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.

Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.

`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.

`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.

Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:54:42 -04:00
parent 8a50d07eef
commit cb0e2d0800
28 changed files with 4424 additions and 2 deletions

424
TODO.md
View File

@@ -1858,6 +1858,147 @@ the reference's semantics reproduced faithfully; the third is the port's own.
reentrant" and "do not grow the runtime by a third for a scratch buffer" are both right
and picking between them is a decision.
### Found while writing a game against the interpreter
A complete Breakout, sprites and text grid and keyboard, running for minutes at a time. Nothing
in either corpus runs for minutes, which is why the first of these had never been seen.
30. **A variable created inside a scope costs pool slots that never come back, so a long-running
program has a hard budget of 4096 name creations.** `akbasic_ValuePool` is a bump allocator
on purpose (`include/akbasic/value.h:30`), and the reason given is that "nothing in BASIC
destroys a variable". **Scope exit destroys variables.**
`akbasic_runtime_prev_environment()` (`src/runtime.c:121`) marks a popped environment's
variables `used = false`; `akbasic_runtime_new_variable()` (`src/runtime.c:31`) `memset`s
the slot it hands back, clearing `values` and `valuecount`; and `akbasic_variable_init()`
(`src/variable.c:98`) therefore takes *fresh* slots for a variable whose old ones are still
counted against `obj->next`. Nothing decrements it, so every `GOSUB` that creates a local
leaks the local's slots for the life of the run.
Reduced, and it fails on both builds:
```basic
X# = 0
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
```
`? 110 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots`,
at `LOC# = 1`, on the 4091st call -- 4096 less the handful of names the program itself
declared. Both builds, same line, same count. Declare `LOC#` alongside `X#` and the same
6000 calls cost nothing at all. A `FOR` counter behaves the same way: name one that does
not already exist and every entry to the loop spends a slot.
**What it costs a program:** a game loop that creates one name per tick is dead in half a
minute, and the error names the line that happened to be unlucky rather than the line that
caused it. Breakout ran twenty-five seconds before this was understood. The workaround is
real and not onerous -- declare every name, loop counters included, at the top -- but it
has to be *known*, and right now the only way to learn it is to hit it.
Three ways out, in increasing order of work. Reclaim on scope exit: give the pool a mark
and release, take the mark when an environment is pushed and rewind to it when it pops,
which is exactly the discipline the environments themselves already have and is why they
are a pool in the first place. Or keep the slice on the recycled variable: stop the
`memset` in `new_variable()` from clearing `values`/`valuecount` and let
`variable_init()`'s existing "reuse the slice when it is already big enough" branch do the
work -- much smaller, and it fixes the common case of the same-sized scalar being recreated
over and over. Or, at minimum, say so in the manual and in `value.h`'s comment, which
currently states the opposite.
Whichever is chosen, the test is a CTest program that enters and leaves a scope creating a
variable tens of thousands of times and returns zero -- and it wants to exist even for the
documentation-only outcome, pinned at whatever the real budget turns out to be. Item 33
has to be settled with it: while the pool is dry the failure cannot be trapped, so a
program cannot even report this one against itself.
31. **`WINDOW` cannot be reached in the standalone AKGL build.** `akbasic_sink_init_tee()`
(`src/sink_tee.c:143`) wires `write`, `writeln`, `readline`, `clear` and -- conditionally
-- `moveto`, and never sets `window`. The standalone frontend runs the interpreter against
the tee (`src/frontend_akgl.c:215`), so the fully implemented `sink_window()` underneath it
(`src/sink_akgl.c:413`) is unreachable: `WINDOW 0, 0, 10, 10` in the windowed build answers
`? RUNTIME ERROR WINDOW needs a text device with a character grid, and this one has none`.
The verb is only usable by a host that hands the runtime the akgl sink directly.
It looks like an oversight rather than a decision, because `moveto` right above it *is*
forwarded, and with the same reasoning that would apply here. The fix is a `tee_window()`
that forwards to whichever half offers one, offered only when one does, and a case in
`tests/sink_tee.c` beside the `moveto` one. §3's "Still missing from the sink" closes by
calling `WINDOW` "ordinary work here rather than anything blocked", which is now half true:
the work was done and then not plumbed.
Worth deciding at the same time: **a program still has no way to ask how big the text grid
is.** `RGR(1)` and `RGR(2)` give the window in pixels, and nothing gives columns, rows or
the cell size, so anything that wants to place a character *and* a sprite at the same spot
has to hardcode a cell size measured against the bundled font -- which is what the Breakout
in `examples/breakout/characters` does, and it is the one thing in that listing that will break
on a different font or window. Two more `RGR()` indices would answer it.
32. **A character written past a short row's terminator is stored where nothing will render
it.** A grid row is a NUL-terminated string and `putchar_at()` (`src/sink_akgl.c:89`)
writes the character, advances, and terminates -- so `CHAR 1, 40, 1, "#"` on an empty row
stores `#` at column 40 with `text[1][0]` still `'\0'`, and `sink_akgl_render()` draws
nothing at all. The same call on a row already 50 characters long draws the `#` and erases
columns 41 onward, which is the documented truncation and is fine.
It is the *silent nothing* that is the trap: the write succeeds, the cursor moves, stdout's
mirror shows the character, and the window stays blank. Either pad the gap with spaces on
a `moveto` past the terminator, so a positioned write always lands, or say plainly beside
`CHAR` in `docs/11-verb-reference.md` and deviation 49 that a row is written from column 0
or not at all. Padding is the friendlier of the two and costs one loop in `sink_moveto()`;
the argument against it is that it makes `moveto` write to the grid, which it currently
does not.
33. **A `TRAP` handler cannot be entered once the value pool is dry, and the error is dropped
in silence.** `akbasic_trap_set_error_variables()` (`src/runtime_trap.c:36`) sets `ER#`
and `EL#` through `akbasic_runtime_global()` (`:51` and `:53`), which *creates* them if
the program never named them. Creating them needs pool slots. So the one error most
likely to leave the pool empty -- item 30's -- is the one error whose handler cannot be
reached, and because the dispatch fails inside the error path rather than raising, **the
program keeps running with the failed statement's effect quietly missing**.
It is easy to see, and it is much worse than an abort:
```basic
X# = 0
TRAP RANOUT
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
LABEL RANOUT
PRINT "DIED AT X " + X#
QUIT
```
prints `OK 4092`. The handler never ran, nothing was reported, and `X#` is 1908 short of
the 6000 the loop counted -- a wrong answer delivered as a right one. Add `ER# = 0` and
`EL# = 0` at the top, so the dispatch has nothing left to create, and the same program
prints `DIED AT X 4090` from its handler as it should.
Two things want fixing and they are separable. The dispatch should not allocate: create
`ER#` and `EL#` at runtime init, where there is always room, rather than at the moment the
program is in trouble -- they are reserved names and §1's other reserved globals are
already there. And a failure *inside* the trap dispatch should not be swallowed; if the
handler cannot be entered, the original error is the thing to report, and reporting it is
what the untrapped path already does correctly.
`AKBASIC_ERR_BOUNDS` from the pool is documented as "bounded and diagnosable, which is the
point" (`include/akbasic/value.h:34`). With a `TRAP` armed it is currently neither.
## 7. Filing gaps against `libakgl`
When phase 7 or phase 8 needs something `libakgl` does not have, **stop and file it** in
@@ -2285,3 +2426,286 @@ What remains, in priority order:
own body said the fill was filed rather than implemented. Drawing the figure and getting
an outline back is what caught it. `akbasic_GraphicsBackend::filled_rect` is implemented,
recorded by the mock, and reached by no verb at all as a result.
---
## 9. Defects found by writing a game against it
`examples/breakout/sprites/breakout.bas` is a complete Breakout — sprite artwork,
powerups, a stroke-font HUD — written in this dialect, for this interpreter, by somebody who
had not written a line of it before. Eight things came out of that, and they are here for the
same reason §8's three are: **none of them was on any list, and all of them were found by
using the thing rather than by testing it.** The corpus reaches none of them.
Ordered by blast radius. Each has a minimal reproduction that fits on a screen; every one was
reduced against `build/basic`, the stdio build, unless it says otherwise.
1. **Every call to a user-defined function leaks value-pool slots, so a program may call one
about four thousand times and then die.**
```basic
DEF ADDIT(N#)
RETURN N# + 1
R# = 0
FOR I# = 1 TO 8000
R# = ADDIT(I#)
NEXT I#
```
```output
? 5 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots
```
It fails between 4000 and 5000 calls, which is `AKBASIC_MAX_ARRAY_VALUES` (4096,
`include/akbasic/types.h:29`) at one slot per call — the call scope's parameter. **Both
`DEF` forms leak**; the single-expression `DEF SQR1(X#) = X# * X#` fails at the same point.
A `GOSUB` doing the same work runs eight thousand times without complaint, which is the
comparison that isolates it.
`akbasic_valuepool_take()` (`src/value.c:142`) is a bump allocator — `obj->next += count` at
`:160` — and **nothing anywhere returns a slot**. The only reset is
`akbasic_valuepool_init()`, from `src/runtime.c:209` at startup and
`src/runtime_housekeeping.c:53` for `CLR`/`NEW`. Environments *are* released back to their
own pool (§1.4, and §6 item 11 says so), but the value slots the variables in them held are
not: `src/environment.c:321` takes them through `akbasic_variable_init()` and the release
path has no counterpart.
The consequence is a hard ceiling on a whole language feature. A game calling one function
per frame at 30fps gets a little over two minutes. It is why the game in question spells its
pseudo-random generator as a `GOSUB` over globals rather than the `DEF` it wants to be.
Fixing it means giving the pool a free list, or giving each environment a value arena
released with it. The second is closer to the house style and to what §1.4 already does for
environments; it touches `src/value.c`, `src/variable.c`, `src/environment.c` and
`include/akbasic/value.h`. A test belongs in `tests/user_functions.c`: call a `DEF` ten
thousand times and then `DIM` an array.
2. **A `BEGIN` block that is skipped leaks a scope for every loop inside it**, because `FOR`
and `DO` create their environment at *parse* time and the skip is decided at *evaluate*
time.
```basic
N# = 0
LABEL AGAIN
N# = N# + 1
IF 1 = 0 THEN BEGIN
FOR I# = 0 TO 2
N# = N# + 1000
NEXT I#
BEND
IF N# < 40 THEN GOTO AGAIN
```
```output
? 5 : PARSE ERROR Environment pool exhausted at line 5 (32 in use)
```
Thirty-two skips is `AKBASIC_MAX_ENVIRONMENTS` (`include/akbasic/types.h:31`) at one per
skip. Inside a subroutine it announces itself on the very first skip and much more
confusingly — the orphaned scope sits between the routine and its caller, so the `RETURN`
after the block reports from a routine that plainly *was* entered by a `GOSUB`:
```basic
T# = 0
GOSUB DOIT
PRINT "came back"
END
LABEL DOIT
IF 1 = 0 THEN BEGIN
FOR I# = 0 TO 2
T# = T# + 1
NEXT I#
BEND
RETURN
```
```output
? 11 : RUNTIME ERROR RETURN outside the context of GOSUB
```
That is the form it was found in, and it costs an evening because the error names the one
construct that is not at fault.
**The cause is an ordering mismatch and it is exact.** `akbasic_parse_do()`
(`src/parser_commands.c:266`) and `akbasic_parse_for()` (`:849`) both call
`akbasic_runtime_new_environment()` while parsing the line. The block skip is tested in
`akbasic_runtime_interpret_line()` (`src/runtime.c:767`), which runs *after* the line has
been parsed — so a skipped `FOR` pushes a scope, its execution is skipped, and the `NEXT`
that would pop it is skipped too. `BEGIN` itself pushes nothing, which is why a nested
`BEGIN`/`BEND` inside a skipped block is harmless, and a `GOSUB` inside one is harmless for
the same reason.
Confirmed for both loop forms: `DO`/`LOOP` in a skipped block fails identically. Confirmed
for both kinds of enclosing routine: a multi-line `DEF` reports the same thing.
A fix has to either push the loop's scope at execution rather than at parse, or have the
skip release what parsing pushed. Touches `src/parser_commands.c`, `src/runtime.c` and
`tests/structure_verbs.c`, which today only exercises blocks whose bodies are plain
statements.
3. **In the standalone SDL build, nothing the graphics verbs draw can ever be seen.** Not
"does not persist" — *never visible at all*, in any frame.
```basic
GRAPHIC 1, 1
COLOR 1, 3
BOX 1, 100, 300, 700, 500
PRINT "THE TEXT SHOWS AND THE BOX DOES NOT"
LABEL SPIN
GOTO SPIN
```
The text appears; the box never does. Two mechanisms combine, and each alone would be
survivable:
- `akbasic_sink_akgl_render()` fills **every row of the text area, opaque, every frame**
(`src/sink_akgl.c:564`), and the area is the whole window — `state->rows = h / cellh` at
`:505`. `akbasic_frontend_akgl_pump()` calls it after `akbasic_runtime_run()` and before
`SDL_RenderPresent`, so it paints over everything the program drew during those steps.
The comment at `:564` explains why it must repaint rather than track dirty rows, and that
reasoning is sound; what it does not account for is that the rows it owns are all of them.
- `WINDOW` would shrink the text area out of the way, and the akgl sink implements it
(`sink_window`, `src/sink_akgl.c:413`) — but **the tee sink in front of it never assigns
`obj->window`** (`src/sink_tee.c:143-151` assigns `clear` and conditionally `moveto`, and
stops). So `WINDOW` refuses with "needs a text device with a character grid"
(`src/runtime_console.c:179`) in the one build that has a character grid.
The result is that **chapter 6 cannot be run in the interpreter it documents**. Its figures
are right because `tools/screenshot.c` deliberately omits the text layer — the comment in
that file says so — so the harness that proves the chapter is exactly the harness that hides
this. `docs_screenshots` passes and always would.
Sprites are unaffected: `akbasic_sprite_akgl_render()` runs after the sink, which is why the
game draws its entire screen by `SSHAPE`-ing what it drew and installing it with `SPRSAV`.
That is a fine trick and it should not be the only way.
The tee half is three lines and is worth doing whatever is decided about the rest.
4. **Mixed-type arithmetic is decided by the left operand alone, and nothing says so.**
```basic
A# = 3
PRINT A# * 0.45
PRINT 0.45 * A#
```
```output
0
1.350000
```
`akbasic_value_math_plus()` and its siblings branch on `self->valuetype`
(`src/value.c:510`, `:512`, `:539`) and convert the right operand to match, so an integer on
the left silently truncates a float on the right. **This is inherited, not introduced** —
`deps/basicinterpret/basicvalue.go:190-193` has the same shape — and §6 item 5 already fixed
the other half of that expression without changing the branch.
It may well be intended; a dialect is allowed to say the left operand wins, and this one is
consistent about it. The defect is that **it is documented nowhere**. Chapter 3's "Numbers"
does not mention it, chapter 13 does not list it among the differences, and a C128 promotes
to float instead. Nothing fails when you get it wrong — the program computes something else
and carries on.
It cost the game two live bugs before either was noticed. `SPD% = 5.6 + LEVEL# * 0.45`
evaluated to a flat 5.6, so no level ever ran faster than level one; and `0 - BLVX%(B#)`,
the obvious way to reverse a ball, quantised its velocity to whole pixels on every bounce
and bled speed out of it. Both read correctly. Neither produced a diagnostic.
The cheap fix is documentation: a paragraph in chapter 3 and a row in chapter 13. The other
fix is promotion, which would be a real change of semantics and wants its own decision.
5. **A drawing that spans a 256-line batch boundary is torn, not merely transient.** §5 and
chapter 13 record that drawing does not persist across frames. What neither says is that a
single uninterrupted sequence of drawing verbs longer than one `akbasic_runtime_run()` budget
comes back **half drawn**, because the present in the middle of it discards the buffer — so
an `SSHAPE` at the end captures only the statements issued since that present, over whatever
the frame before left behind.
Measured against `AKBASIC_FRONTEND_STEPS_PER_FRAME` of 256, in the SDL build: after
synchronising to a jiffy edge, 110 `DRAW` statements in a `FOR` loop (220 executed lines)
are captured whole; 125 (250 lines) capture nothing at all; 140 (280 lines) capture the last
fifteen. A program cannot see the boundary except by watching `TI#` change, which is what
the game's `PACE` routine does and what makes the whole thing workable.
This is worth a paragraph in chapter 13 beside the existing note, because "redraw it every
frame" is the advice there and it is not sufficient advice — the redraw also has to fit.
6. **`SSHAPE` and `GSHAPE` ignore the subscript on a string array element.** Needs a graphics
device, so this one is reduced against `build-akgl/basic`.
```basic
DIM SH$(4)
SSHAPE SH$(2), 0, 0, 61, 4
PRINT "[" + SH$(0) + "] [" + SH$(2) + "]"
```
```output
[SHAPE:0] []
```
The handle lands in `SH$(0)`; `SH$(2)` stays empty. `GSHAPE SH$(2)` then stamps whatever is
in `SH$(0)`. Ordinary assignment and `PRINT` honour the subscript, so a program that keeps
several saved shapes in an array gets every one of them resolving to element zero, silently.
`shape_variable()` (`src/runtime_graphics.c:625`) takes `arg->identifier` and calls
`akbasic_environment_get()` — it never evaluates the subscript — and both verbs then use a
literal zero: `src/runtime_graphics.c:688` writes with `zerosubscript`, `:724` reads with it.
**`SPRSAV` is the counter-example and the model for the fix**: it calls
`akbasic_runtime_evaluate()` on its argument (`src/runtime_sprite.c:480`) and handles an
array element correctly.
The game keeps six brick stamps in six separate scalars because of this, and the workaround
is not obvious from the failure — every brick simply comes out the colour of the last one
captured. `tests/graphics_verbs.c` is where the regression goes.
7. **`GRAPHIC CLR` is documented and refused.** `docs/06-graphics.md:65` and
`docs/11-verb-reference.md:54` both give the form as `GRAPHIC mode | CLR`.
```basic
GRAPHIC CLR
```
```output
? 1 : PARSE ERROR 1 at 'CLR', Expected expression or literal
```
`GRAPHIC` parses with `akbasic_parse_arglist` (`src/verbs.c:86`), so `CLR` scans as the `CLR`
verb rather than as a keyword argument. `GRAPHIC 5` does the job and is what the game calls.
Either the parse handler learns the word or both documents lose it; the second is smaller and
the first is what a C128 programmer will type.
Worth noting because `docs_examples` cannot catch it: every fenced `GRAPHIC CLR` in the
documentation is prose, not a tagged runnable block.
8. **A `DATA` line holding a negative number cannot be executed.** §4 settled that "`DATA` at
run time is now a no-op: it is a declaration, and reaching the statement means walking past
it." Walking past a negative one raises instead:
```basic
READ A#
PRINT "READ GAVE " + A#
DATA -5
PRINT "REACHED THE LINE AFTER THE DATA"
```
```output
READ GAVE -5
? 3 : PARSE ERROR Expected literal
```
`READ` handles the value correctly — the negative comes back intact — so this is only the
statement's own argument parse, in `akbasic_parse_data()`
(`src/parser_commands.c:934`), refusing a leading `-` where the prescan accepted it. The
positive form falls through cleanly.
Reachable in ordinary code: a table of `DATA` at the foot of a program is walked into by any
routine above it that ends in a fall-through rather than an `END`, and a table of angles or
offsets is exactly where negative numbers live. `tests/read_data.c`.
**A note on what this list is.** Items 1, 2, 6, 7 and 8 are defects by any reading. Item 3 is a
design consequence nobody chose. Item 4 may be intended and is a documentation gap either way.
Item 5 sharpens something already recorded. None of them is a complaint about the interpreter,
which was a pleasure to write a real program against — they are the kind of thing one real
program finds and a test suite written alongside the implementation structurally cannot,
because the suite tests the constructs the implementer had in mind and a game reaches for
whatever it needs.