Commit Graph

122 Commits

Author SHA1 Message Date
dd10dc143a Close the remaining cold-read gaps: bind, labels, menus and main's shape
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 5m32s
akbasic CI Build / sanitizers (push) Failing after 15m58s
akbasic CI Build / coverage (push) Failing after 20m7s
akbasic CI Build / mutation_test (push) Failing after 4m29s
akbasic CI Build / akgl_build (push) Failing after 12m28s
Three more Haiku-class cold reads of the chapters, each against the
amended text. What each surfaced is now shown rather than described: the
akbasic_host_register_type()/akbasic_host_bind() boot calls, the
declare_play() label listing, the akgl_UiMenu static and its
handle_event signature, one control-handler pair, and main()'s
ATTEMPT/HANDLE_DEFAULT/FINISH_NORETURN shape with the CATCH-inside-
ATTEMPT rule stated. By the fourth read the generated player.c and
enemies.c compiled untouched and every remaining guess was a tuning
value the chapters deliberately leave open.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 09:22:43 -04:00
54ab85a276 Benchmark the boundary and close the cold read's tutorial gaps
The interop test now ends with a measured comparison: 24,000 formation
updates through the script boundary against a line-for-line C
translation of the same state machine. 881 us against 0.01 us per call
on this machine, quoted verbatim in the new chapter 21 Step 11 with the
architectural decisions it prices.

A Haiku-class cold read of the chapters produced a build whose failures
were all mechanical -- invented include paths, never-shown sink statics,
guessed status codes and character names. The chapters now carry the
include lists, the script.c statics, the status-code roster, the
sprite/character table, the full CMake recipe and the explosion spawn's
HANDLE example, so none of those have to be guessed again.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 09:01:59 -04:00
d5a0edd692 Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.

docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
47c6be58c5 Update the breakout chapter's scope-pool figure to this branch's 12
The environment pool shrank from 32 to 12 in the memory-reduction work
and the chapter's exhaustion transcript still asserted the old number,
which is a docs_examples failure on every run of this branch.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:42 -04:00
27837aeabc Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.

Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).

Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00
743e610f8f Reset scratch per line and unwind dead scopes in host function calls
akbasic_runtime_call_function()'s body loop drives process_line_run()
directly, skipping the per-line prologue akbasic_runtime_step() provides.
The call environment's value scratch therefore accumulated across the
whole body, and any body past about ten real lines died with 'Maximum
values per line reached' -- a limit that is supposed to be per line. The
loop now runs the same prologue step() does.

A body that died also left its call scopes active: nothing popped them,
so a host absorbing script errors drained the twelve-slot environment
pool after twelve dead calls. The loop now unwinds to the caller's
environment on every exit path.

New: akbasic_runtime_clear_error(), the missing half of host revival. A
run's first BASIC-level error latches deliberately, and set_mode(RUN)
alone cannot un-decide that; a host that absorbed the error calls this
beside it. Both defects and the revival dance are pinned in
tests/user_functions.c.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:05 -04:00
dde1d91c6e Reset the scanner's leftover token type between lines
The REM early-exit leaves tokentype holding AKBASIC_TOK_REM, and the scan
loop's post-switch check reads it before the next line's first character
has assigned anything. A line opening with whitespace then re-triggered
the REM break and scanned to nothing: every indented line after a REM was
silently skipped. Numbered programs never saw it -- the line number is the
first token and overwrites the leftover -- which is why the whole golden
corpus missed it and the unnumbered, indented galaga.bas found it.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:46:51 -04:00
17af2d406c Cut akbasic_Runtime's static footprint from 10.75 MiB to 2.40 MiB
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m29s
akbasic CI Build / coverage (push) Failing after 3m40s
akbasic CI Build / sanitizers (push) Failing after 4m37s
akbasic CI Build / mutation_test (push) Failing after 3m35s
akbasic CI Build / akgl_build (push) Failing after 7m20s
Nothing in this interpreter mallocs; every pool is a fixed array sized by an
AKBASIC_MAX_* constant, so sizeof(akbasic_Runtime) is a compile-time number
and most of it was headroom nobody was using. Measured concurrent-use
high-water marks off examples/breakout and examples/megademo -- the two most
demanding programs this interpreter runs -- against each pool's ceiling:

  AKBASIC_MAX_ENVIRONMENTS   32 -> 12    (measured peak concurrency: 6-7)
  AKBASIC_MAX_FUNCTIONS      64 -> 8     (measured: 0, neither program uses DEF FN)
  AKBASIC_MAX_ARRAY_VALUES 4096 -> 2048  (measured peak: 1618 slots)
  AKBASIC_MAX_SOURCE_LINES 9999 -> 2048  (measured: ~1270-1496 non-blank lines)
  AKBASIC_SYMTAB_MAX_SLOTS  256 -> 172   (no caller ever requests more than 128)
  AKBASIC_SYMTAB_MAX_KEY     64 -> 24    (longest identifier measured: 11 chars)
  AKBASIC_MAX_LINE_LENGTH   256 -> 80    (Commodore BASIC's own line limit)

AKBASIC_MAX_VARIABLES (128) is untouched on purpose: breakout alone reaches
121 of 128 concurrent named variables, so it has the least slack of any pool
measured and is not a shrink candidate.

akbasic_Variable.name shrinks from AKBASIC_MAX_STRING_LENGTH (256) to
AKBASIC_SYMTAB_MAX_KEY: every variable name is registered with
akbasic_symtab_set() right after this field is populated
(akbasic_environment_create(), src/environment.c), and that call already
refuses anything AKBASIC_SYMTAB_MAX_KEY characters or longer. The wider field
was headroom nothing could ever put a byte into.

Two defects surfaced while testing the line-length drop against the golden
corpus, both fixed here because the 80-byte ceiling makes them routine rather
than theoretical:

- sourcepath (runtime.h) was borrowing AKBASIC_MAX_LINE_LENGTH by accident.
  It holds a directory, not a line of BASIC, and this checkout's own test
  paths are 81+ characters deep -- every golden test failed to load until
  this split into its own AKBASIC_MAX_SOURCE_PATH_LENGTH, backed by PATH_MAX
  the way libakerror already sizes its own path buffers.

- src/sink_stdio.c's stdio_readline() called aksl_fgets() but never checked
  its own documented contract: a full buffer with no trailing newline means
  the line was longer than the buffer, and the unread remainder is still in
  the stream. Unchecked, the next readline() picks that remainder up as its
  own statement -- a real line silently becomes two wrong ones instead of a
  clean AKBASIC_ERR_BOUNDS refusal. At 256 bytes this was theoretical; at 80
  it is not, so it now refuses loudly.

tests/value_pool.c's test_pool_is_untouched_by_scopes() was pinned to the old
4x1024=4096 pool math (four max-size arrays proving nothing leaked); rewritten
to 2x1024=2048 for the same proof against the new AKBASIC_MAX_ARRAY_VALUES.

Known consequence, tracked in andrew/akbasic#32 rather than worked around
here: two files in the protected tests/reference/ corpus
(language/functions/mod.bas, language/flowcontrol/nestedforloopwaitingfor
command.bas) have 82-character lines and cannot be shortened -- MAINTENANCE.md
and CMakeLists.txt:585 are explicit that tests/reference/ is never edited to
suit this interpreter. Twelve tests/language/ cases and one docs/18 line are
in the same position but are this project's own content. Shipping 80 anyway,
with the fallout tracked rather than hidden, was an explicit call on this PR
rather than something decided here.

Verified: cmake --build build-akgl && ctest --test-dir build-akgl, 97/112 (15
known failures, all AKBASIC_MAX_LINE_LENGTH-related, filed as #32).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 21:44:16 -04:00
fac84acdaa Merge pull request 'Port onto libakstdlib 2b79aca and convert the eight bool predicates (libakstdlib#26)' (#31) from libakstdlib-26 into main
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m39s
akbasic CI Build / sanitizers (push) Successful in 5m1s
akbasic CI Build / coverage (push) Successful in 4m47s
akbasic CI Build / akgl_build (push) Successful in 8m7s
akbasic CI Build / mutation_test (push) Successful in 29m11s
Reviewed-on: #31
Reviewed-by: andrew <andrew@aklabs.net>
2026-08-03 21:25:07 -04:00
0d6a817b7c Bound CI build parallelism so five concurrent jobs stop starving the runner
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m47s
akbasic CI Build / coverage (push) Successful in 4m4s
akbasic CI Build / sanitizers (push) Successful in 5m1s
akbasic CI Build / akgl_build (push) Successful in 9m29s
akbasic CI Build / mutation_test (push) Successful in 22m11s
Run #31's sanitizers job failed, and not on anything in src/. The ASan build
reached 100% and the runner's Docker daemon went away before ctest emitted a
single line: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock".

The tree under test is identical to run #30's -- 9e5496f touched only this
file -- and run #30's sanitizers job passed 112/112 in 83 s. What changed is
that #30 was still losing akgl_build and coverage early, so #31 was the first
push on which all five jobs did real work at the same time. All five say
runs-on: ubuntu-latest, so all five share one runner, and every build step
asked for a bare --parallel, i.e. nproc compilers each.

The timestamps are unambiguous. Through 20:36:16 the sanitizers job links an
executable in well under a second. akgl_build starts at 20:36:02 and begins
compiling SDL, SDL_image, SDL_mixer, SDL_ttf, libccd and clay; mutation_test
starts at 20:36:41. From 20:36:16 onward a single `Linking C executable` step
takes one to two minutes -- a sixtyfold regression with no source change --
and at 20:49:55 the daemon is gone. ASan links are memory-hungry and the SDL
tree is large; the runner ran out.

The only lever on that is how many compiler and linker processes exist at
once, so bound each build to 2. Worst case is now 8 rather than 5 x nproc.
mutation_test drives its own builds through the harness and is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:32 -04:00
78b7b55103 Clone libakgl's other two submodules, and pin upload-artifact to v3
Two more gaps that only became visible once the errno fix let the jobs
run far enough to hit them.

akgl_build cloned six of libakgl's submodules and needs eight. libakgl
does not add_subdirectory deps/libccd or deps/clay -- their own
CMakeLists are unusable as subprojects -- it compiles them into itself,
so nothing declares them and configuration dies at
deps/libakgl/CMakeLists.txt:282 with "File
deps/libccd/src/ccd/config.h.cmake.in does not exist". deps/tg stays out:
nothing references it.

The X11 packages from the previous commit did their job -- SDL now
configures the lot: "X11 libraries: xcursor xdbe xfixes xinput2 xrandr
xscrnsaver xshape xsync xtest".

coverage passes its gate now (112/112, lines 94.1% against a 90 floor)
and failed on the upload instead. actions/upload-artifact@v4 bundles
@actions/artifact v2, whose isGhes() treats any GITHUB_SERVER_URL that is
not github.com as GitHub Enterprise Server and refuses outright. @v3 uses
the older artifact API, which Gitea 1.25.3 does implement. The step had
never had a file to upload before -- gcovr was never reached, so it
warned "No files were found" and passed -- which is why this surfaced
only now. Same class of accommodation as annotate_only on the junit
reporter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:32 -04:00
c8d90c2743 Install the build dependencies CI has been missing since run #2
Every job in ci.yaml has failed on every run since #2, on main and on
every branch, for two reasons that have nothing to do with any of the
code under test.

moreutils, for errno(1). deps/libakerror/scripts/generrno.sh shells out
to it to generate its errno name table and to stamp
AKERR_LAST_ERRNO_VALUE into the generated akerror.h. The runner does not
have it, and its absence does not fail the build -- which is what made
this expensive to find. The script prints "errno: command not found",
emits an empty akerr_init_errno() so no errno ever gets a name, and
substitutes an empty AKERR_LAST_ERRNO_VALUE, so every code in
libakerror's reserved band collapses from (134 + n) to ( + n), i.e. to n.
AKERR_OUTOFBOUNDS is (AKERR_LAST_ERRNO_VALUE + 2) and therefore becomes
literally 2, so ERR(2) answers "Out Of Bounds Error" where ENOENT's "No
such file or directory" belongs. That is the docs_examples failure at
docs/15-error-codes.md:105, and it took cmake_build, sanitizers and
coverage down with it; mutation_test then aborted on "baseline is not
green" because the same suite is its baseline.

Reproduced locally by shimming errno(1) out of PATH: identical failure,
same test #47, same two lines of diff. With errno present the suite is
112/112.

The nine X11 dev packages, for akgl_build, which has never once been
green. SDL_X11 and its sub-options default ON on Linux, and CheckX11()
calls SDL_missing_dependency() -- a hard CMake error, not a downgrade --
for any whose header is absent. The runner has libx11-dev but none of
the extension packages, so configuration stopped at the first of them:
"Couldn't find dependency package for XCURSOR". The list is SDL's own,
from deps/SDL/docs/README-linux.md, reduced to the X11 entries; the
audio, Wayland, KMSDRM and Vulkan backends all degrade to "not found"
and skip themselves rather than erroring, so they cost nothing to leave
out. Turning the missing options OFF would also configure and is the
wrong answer -- the point of that job is that an AKGL build is a real
SDL program. The tests still run headless under SDL_VIDEODRIVER=dummy.

release.yaml's full_mutation job builds the same tree and gets moreutils
for the same reason.

No source file changes. The port itself was never the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:32 -04:00
b434be1901 Port onto libakstdlib 2b79aca and convert the eight bool predicates
akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2%
bypassed, against 86.4% on the same tree before this. The submodule bump
669b2b3 -> 2b79aca needed no source change of its own: the release is
drop-in for what akbasic already used.

Seven of the eight sites the earlier port left on raw libc change their own
signature rather than swallowing an error, per andrew's ruling on
libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end,
peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's
scroll/newline/putchar_at/echo_line/edit_key chain all return an
akerr_ErrorContext * and hand the answer back through an out parameter.
is_waiting_for and is_waiting_for_any are a public header change; every
call site that used one as a term in a condition hoists it into a
statement first.

verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the
comparator's signature, so there is no out parameter to report through --
which is what libakstdlib#38 concluded. It carries a comment saying so and
saying why the bypass is safe there.

Six snprintf sites stay raw because they want truncation as an answer
rather than an error, and aksl_snprintf cannot express that until
libakstdlib#34 hands the required length back. Each of the six says so at
the site. Two of them, in host.c, are a latent defect rather than a
decision: a host type name over 31 characters truncates silently and two
sharing a prefix then collide, where structtype.c refuses the same case.

DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the
PASS in it returned past CLEANUP, so a scan error left the file open.
Hoisting the loop into its own helper to convert fgets fixes it.

Refs libakstdlib#26, libakstdlib#38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:32 -04:00
330d731cfe Merge pull request 'Make GRAPHIC select the text plane (#23)' (#29) from 23 into main
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m42s
akbasic CI Build / coverage (push) Failing after 3m41s
akbasic CI Build / sanitizers (push) Failing after 4m41s
akbasic CI Build / mutation_test (push) Failing after 3m28s
akbasic CI Build / akgl_build (push) Failing after 5m26s
Reviewed-on: #29
2026-08-03 12:04:12 -04:00
3342f2b569 Make GRAPHIC select the text plane
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m22s
akbasic CI Build / sanitizers (push) Failing after 4m43s
akbasic CI Build / coverage (push) Failing after 3m43s
akbasic CI Build / akgl_build (push) Failing after 4m48s
akbasic CI Build / mutation_test (push) Failing after 3m31s
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 08:08:34 -04:00
c5d13f00f6 Repoint the TODO.md citations that no longer resolve
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 4m7s
akbasic CI Build / coverage (push) Failing after 3m33s
akbasic CI Build / akgl_build (push) Failing after 4m39s
akbasic CI Build / mutation_test (push) Failing after 3m32s
akbasic CI Build / sanitizers (push) Failing after 14m32s
Its own SS0-SS9 structure survived the move to the tracker, so 133 citations
still resolve and are left alone. Thirteen did not.

Six named another repository's TODO.md by a section number: libakstdlib 1.6,
2.2.2 and 2.3, and libakgl's file. Those repositories dropped their numbering
entirely, so each now names UPGRADING.md, an issue, or the tracker.

Three cited 'TODO.md section 12', which has never existed here -- the defect
list is SS6. TODO.md itself caught two others of that class earlier. The
CMakeLists comment carried a stale premise with it: eleven defects
'deliberately reproduced and not yet fixed' stopped being the rule when SS0.1
retired the fidelity constraint.

Four told a reader to record work in TODO.md; the scanner defect is issue #4,
the audio_tables mutation gap is #25, and the UI gaps are libakgl #79 and #80.

Verified: cmake --build build && ctest --test-dir build, 112/112.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 22:01:28 -04:00
18b30b753b Merge pull request 'Move outstanding work from TODO.md into the issue tracker' (#27) from todo-to-issues into main
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m16s
akbasic CI Build / sanitizers (push) Failing after 4m29s
akbasic CI Build / akgl_build (push) Failing after 0s
akbasic CI Build / mutation_test (push) Failing after 0s
akbasic CI Build / coverage (push) Failing after 4m9s
Reviewed-on: #27
2026-08-02 19:46:56 -04:00
e188fecb6d Point the agent instructions at the issue tracker for outstanding work
This repository has no AGENTS.md; CLAUDE.md and MAINTENANCE.md carry the same
rules, so both are updated.

Outstanding work is an issue on the forge now, with the file and line, the
functional consequence, and what closing it would touch. TODO.md is the record
-- settled design decisions, the deviation register, defects already fixed, and
the reasoning behind the measurements -- and its row in CLAUDE.md's reading
table says so, with the tracker listed above it.

The dependency rule gains the correction this migration produced. 'Never work
around a missing dependency capability here' now says to open an issue in that
repository's tracker, and says plainly that recording it here instead is not
filing it: two akgl_ui gaps sat in TODO.md for a release on the reasoning that
changing a submodule is that repository's decision, which is true of changing it
and not of reporting it. Both are libakgl issues now.

Cross-repository citations of deps/*/TODO.md sections point at the trackers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 19:25:05 -04:00
4163144c2a Move outstanding work from TODO.md into the issue tracker
Twenty-six issues on source.starfort.tech/andrew/akbasic, labelled by kind and
blast radius and milestoned by what they can land in: 0.1.x for anything that
changes no public contract, 0.2.0 for new verbs and observable behaviour
changes, 1.0.0 for the design decisions. Everything carries status::grooming.

Block surgery rather than a rewrite. Every open item is replaced by a line
saying what it was and which issue carries it; everything else -- the settled
design decisions, the deviation register, the fixed defects and the reasoning
behind the measurements -- is byte-identical. 3338 lines to 2966.

Two corrections found while doing it. The "what remains, in priority order"
list named groups A, D, F, J and H as outstanding language work; SS4's own table
shows every one of them done, and what actually survives is one piece of
structural work that is not a verb -- block skipping by source line, which is
why a whole FOR/NEXT on one line never loops. And the two UI gaps SS7 recorded
and declined to file are now libakgl #79 and #80: that section's rule is right
that changing a dependency is that repository's decision, and it does not
follow that reporting the gap is.

Cross-repository references to deps/*/TODO.md sections are repointed at the
trackers that now hold them, here and in MAINTENANCE.md.

Verified: cmake --build build && ctest --test-dir build, 112/112.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 19:08:31 -04:00
3b32a682a1 Give BASIC menus, dialogs and HUD labels over libakgl's UI helpers
Group K, and the first verbs to reach the akgl_ui subsystem 0.9.0 brought
in: MENU and GETMENU and RMENU, DIALOG, HUD and UISTYLE. A program that
wanted a title screen had to draw one out of CHAR and GETKEY, which is
what both breakout tutorials make a reader do.

The interesting part is the impedance mismatch. libakgl's UI is immediate
mode -- widgets are re-declared inside a frame bracket every frame and
clay borrows their text until the bracket closes -- and a BASIC program
says MENU 1, "START" on line 100 and expects it up on line 900, several
hundred frames later. So src/ui_akgl.c is retained on this side and
immediate on that one: the record's entry points are setters that copy
into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole set
once a frame from the host's pump. No BASIC string, which lives in the
per-line value pool, is ever what clay is handed.

The shapes are borrowed rather than invented. MENU retires the way SOLID
does -- no entries retires one, no arguments retire them all. GETMENU
holds the step loop the way GETKEY does, so parking is not blocking: the
step still returns, the host keeps its frame rate, and the sprite, audio
and collision services keep running underneath because they run before
the blocking checks. RMENU(n,1) reads and clears the way BUMP() does.
Withdrawing the device or retiring the menu releases a holding GETMENU
with 0 rather than wedging the script, which is akbasic_input_service()'s
rule for a withdrawn keyboard.

One thing a program has to know, and docs/19-user-interface.md says it
twice: a menu that is up owns the cursor keys and Return. It has to, and
retiring it gives them back -- forget the MENU n before an INPUT and the
INPUT never sees the Return that ends it.

akbasic_runtime_set_ui() is its own function rather than a fifth argument
to akbasic_runtime_set_devices(), whose signature has twenty-eight call
sites in tests and documentation that are about something else.

deps/libakgl is not touched. akgl_UiAnchor has the four corners and dead
centre, so HUD offers exactly those five; TODO.md records what a
top-centre and bottom-centre would cost upstream, along with the three
other things this deliberately leaves out. No new error code either --
DEVICE, BOUNDS, SYNTAX and TYPE cover the group, and 520 stays free.

tools/screenshot.c had to learn that "needs a font" and "draws the text
grid" are two questions. They were one, and a UI figure came out black:
the text layer owns every pixel of the rows it covers and painted over
the widgets. The new ui=1 fence attribute asks for the first without the
second; MAINTENANCE.md documents it.

112/112 in both configurations, 112/112 under ASan and UBSan, coverage
94.1% against the 90% gate with src/runtime_ui.c at 99% of lines and
100% of functions, doxygen clean, and the four new figures byte-identical
on a re-render. TODO.md section 8's gate table was stale on several
counts besides these and is refreshed with measured numbers.

Co-Authored-By: Tachikoma (Claude Code Opus 5 1M) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 18:37:10 -04:00
5061419768 Take libakgl 0.9.0, and shadow set_property(TEST) so it configures
0.9.0 is the akgl_ui subsystem: a vendored clay, an arena, menus, HUDs and
dialogs, reached through a new akgl/ui.h this project does not include. Every
header the akbasic_akgl target actually compiles against is byte-identical to
0.8.0, so unlike the 0.8.0 bump there is no sizeof to get wrong. The floor in
include/akbasic/akgl.h moves to 0.9.0 anyway, on the standing rule that the
soname carries MAJOR.MINOR while the major is 0.

It does not configure as it stands. 0.9.0 moved eight test property calls off
set_tests_properties -- correctly, because that command splits a
semicolon-separated value and had been silently reducing its LD_LIBRARY_PATH
prepend list to one directory -- onto set_property(TEST ...). This repository
suppresses add_test() while it pulls its dependencies in, so those test names do
not exist, and set_property errors on an unknown test name where
set_tests_properties was silent: eight hard configure failures. Shadow
set_property in its TEST form only; every other form has to pass through, since
the dependencies set target and directory properties their own builds need.

libakgl's status band grew to seven codes with AKGL_ERR_UI and it now owns 256
to 262, so the coordinated range map, docs/15-error-codes.md and both header
comments follow. TODO.md section 5's persistent-drawing-layer item still stands:
0.9.0's arena draws inside the frame like everything else, and there is still no
render-to-texture layer.

Both configurations build and both suites pass -- 110/110 without libakgl,
111/111 with it, akgl_typing included.

Co-Authored-By: Tachikoma (Claude Code Opus 5 1M) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 16:56:52 -04:00
16aa052119 Decompress a full-screen picture and six frames of video from strings
The megademo gains a scene: an 800x600 vaporwave sunset carried inside
the listing, then six looping delta frames of full motion video -- the
floor grid rolling forward and the sun's slices crawling, about 220
bytes a frame. DATA cannot carry an image (512 items, ~350 owned by the
stroke font), so the picture rides in RLE-encoded string literals
decoded with INSTR: 2 KB of base frame, 1.3 KB of video. Row records
jump the decoder between changed rows, an unreachable colour is the
skip, black draws so a delta can erase, and the rays are struck live
over every frame rather than encoded. The 63-byte bird gliding over it
is the one image DATA does have room for, via SPRSAV's type-in form.

examples/megademo/vaporwave.py composes the image in palette space,
dithers by row so the gradients band instead of shattering the RLE,
proves base-plus-deltas reproduces every frame against a simulation of
the decoder, and owns the generated block between the PICTURE markers.
TODO.md section 4 records the DATA ceiling and the wanted verb.

Co-Authored-By: Claude Code (Fable 5) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 16:26:56 -04:00
96cfd21de8 Add the AKLABS megademo example
A six-scene demoscene production that pushes the interpreter to its
edges on purpose: an LCG where there is no RND, redrawn strokes where
the palette cannot be written, the sprite bank as the scrolltext, a
supernova and black hole out of SOLID/COLLISION/RSPPOS, wireframe 3D
from two rotations and a perspective divide, and a single-queue
beeper-style soundtrack with a noise drum break.

The same file runs in the standalone frontend and in the clockless
screenshot host, which gets each scene as a still. Three interpreter
lessons learned the hard way are recorded in the example's README and
TODO.md section 4: a scalar first assigned in a GOSUB dies with its
scope, predeclaring scratch runs the 128-slot variable pool dry, and
a TI# busy-wait starves the PLAY queue of its one release per frame.

Co-Authored-By: Claude Code (Fable 5) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 15:58:47 -04:00
e2a3f08613 Stop chapter 18 contradicting itself about FILLROW and DRAWHUD
The assembly step still carried the pre-conversion FILLROW, without the
SOLID registration Step 8 spends a section teaching -- a reader following
the assembly order got a wall the ball passes straight through.

DRAWHUD cleared the graphics layer and never stamped the HBL$ eraser Step 6
tells you to make. Both together took the field down every time a digit in
the HUD changed. It is GRAPHIC 1, 0 and a GSHAPE of HBL$ at 0, 0, and the
difference between that and the routines that draw the field is now stated
rather than left to be discovered.

Also say where HUDTEXT is called from, which nothing did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 13:35:14 -04:00
8fa17301e1 Give chapter 18 the six pieces a reader had to invent
A reader building the game from the chapter alone had to guess at all of
these, and three of them are code the chapter describes and never shows:

- the HUD eraser stamp, promised in Step 6 as "make a second one while you
  are here" and then never given
- the gem roll at the end of ONEBRICK, without which nothing ever drops
- BX1# and BY1#, which SPAWNGEM reads and only ONEBRICK can set

And three rules the chapter leans on without stating:

- MID and INSTR count from zero, which is what makes a glyph number an
  array index with no adjustment
- sprite n sets bit 2^(n-1), which is where 16, 32 and 64 come from
- states 5 and 6 have no tick routine on purpose; that is what a pause is

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 13:26:47 -04:00
2688183ef0 Say where the collision handler is armed, and what BYE's last two verbs do
A reader building chapter 17 from the text alone had to infer that
COLLISION 2, BRICKHIT belongs in the setup block rather than the frame
loop, and met SCNCLR and QUIT for the first time in the assembly step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 13:20:34 -04:00
876ebedfba Close out the collision conversion in TODO.md
§6 item 39 is done: both listings and both tutorials are on SOLID,
COLLISION 2 and RCOLLISION. Record the two traps the conversion turned up
that no error reports -- BUMP's mask is by sprite number, so the artwork
game's balls are bits 16, 32 and 64; and a bare SOLID after the fill loop
retires the rectangles it was meant to precede.

§6 item 38 and §9 item 9 carried statements the work since made false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 13:15:02 -04:00
ff1730e424 Teach the artwork tutorial the converted listing
Chapter 18 still taught the machinery the listing lost when it moved onto
SOLID and the persistent drawing layer: the frame-boundary PACE routine, the
flattened live list, the SSHAPE pool accounting, and the sixty lines of
BALLBRICKS/TESTCELL that computed a minimum translation axis by hand.

Step 8 now registers each brick with SOLID, arms COLLISION 2 and reads the
contact back with RCOLLISION, including the point that costs an evening if it
is missed: the BUMP mask is by sprite, and the balls are sprites 5, 6 and 7,
so their bits are 16, 32 and 64. The FOR-with-equal-bounds rule that every
one-item case in the chapter depends on is now stated where it is first used
rather than referred to from a step that no longer exists.

The listing loses six declarations orphaned by the conversion and the comment
blocks that still described the captures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 13:13:51 -04:00
61f60a8859 Stop the artwork breakout drawing its screen into two sprites
It captured the HUD strip into sprite 1 and the whole play field into sprite 2,
and its own header called them "the screen". That was never a choice: a drawing
lasted one frame, so a sprite was the only thing the interpreter would redraw for
nothing. Both slots are free now, and five things went with them.

**`WINDOW 0, 35, 49, 36` is the first executable line.** The text layer repaints
every row it owns and by default owns the whole window; moved to two rows at the
bottom, it leaves the other thirty-five to the drawing verbs, and what the
program draws stays.

**`PACE` is gone.** It spun on `TI#` until a batch boundary so an `SSHAPE`
capture would not be cut in half by a present. Nothing is captured, so a drawing
spanning two batches simply arrives over two frames and the layer keeps both
halves. The 256-line drawing deadline that shaped every draw routine in this
listing does not apply to it any more.

**The flattened live list is gone** -- `BUILDLIVE`, `BUILDROW`, `STAMPROW` and
the four arrays behind them. They existed so a full-field redraw could be one
stamp and an advance per brick, and a full-field redraw existed because removing
one brick meant rebuilding all sixty. A broken brick is now erased in place with
a blank stamp, so the field is drawn once per level and not again.

**`BALLBRICKS` and `TESTCELL` are gone** -- about sixty lines that turned the
ball's box into a range of candidate cells, walked them with a nested `DO`, and
computed an overlap rectangle to pick an axis. `SOLID` registers each brick as
the level is laid out, `COLLISION 2` fires, and `RCOLLISION` says which brick,
which way out, how far, and which axis to reverse.

**The `SSHAPE` pool accounting is gone.** `SHN#` counted slots because every
frame's capture spent another and `GRAPHIC 5` had to throw them all away
periodically; eight are now spent once and never again.

Three mistakes worth recording, because each was silent:

- **The bricks were registered and then immediately thrown away.**
  `GOSUB BUILDLIVE` sat *after* the fill loop in `SETUPLEVEL` and meant "rebuild
  the list of what is standing"; replaced in place by a bare `SOLID`, it meant
  "retire everything" and ran after all sixty had been registered. Nothing
  failed -- the ball simply passed through the wall.
- **The handler tested the wrong bits.** `BUMP` is a mask by *sprite*, and the
  balls are sprites 5, 6 and 7, so their bits are 16, 32 and 64. Testing 1, 2 and
  4 asked about the HUD and field sprites that no longer exist.
- **The eraser stamps were undeclared**, so `SSHAPE` filled them inside
  `DRAWPROTOS` and they were empty everywhere else -- exactly the scope trap
  chapter 17 Step 3 is about, found by printing the handle inside the routine and
  again outside it.

Verified by driving it: a copy with a tracking paddle and auto-relaunch breaks
thirteen bricks in sixty seconds with no error line, and the shipped listing runs
clean. Both suites green.

Chapter 18 still describes the architecture this commit removed; that is the rest
of section 6 item 39.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 12:58:08 -04:00
d7a68c6ca3 Teach chapter 17 the collision verbs its listing now uses
The previous commit converted `examples/breakout/characters/breakout.bas` and
left the chapter teaching three routines the listing no longer has. This closes
that.

Step 5 gains the `SOLID` registration beside the `BR#()` array, and says why
there are two records of the same thing: one is the program's, one is the
interpreter's, and the id is the array index plus one so neither has to be looked
up from the other. It is also now the only place cells and pixels meet, which
Step 2 used to promise about Step 10.

Step 10 is rewritten. It was `HITTEST` -- fourteen lines dividing pixels by cell
sizes to recover a grid index -- plus `XBRICK` and `YBRICK` testing the ball's
leading edge, with a paragraph explaining that the cost of testing an edge rather
than a box is missing a brick clipped at the corner by up to seven pixels. All of
that is gone. What replaces it asks four questions: which rectangle, is it still
there, which way out and how far, and which axis to reverse.

Three things in it are worth the reader's attention and get it: `BUMP(2)` is a
separate accumulator from `BUMP(1)` so the handler never sees the paddle; the
guard against being told about an already-broken brick, because a handler runs a
line or two after the overlap; and `SOLID BI# + 1` in `KILLBR`, without which the
ball goes on bouncing off a brick that is no longer drawn. That last one is now a
row in the rules table.

The float note is kept and sharpened rather than dropped. `D%` must be a float
because field 4 is one, and the products land in integer `BX#`/`BY#` -- which is
safe here because a box against a box gives a normal that is exactly -1, 0 or 1,
and would *not* be safe against a circle. Stated as a reason rather than left as
something that happens to work.

Step 9 loses its two brick calls and its `OX#`/`OY#` backup, with a sentence
saying why there is nothing to restore to. Step 16's declaration block and name
count follow, and the skeleton arms the handler.

Every fenced example still runs; both suites green; the game runs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 11:21:32 -04:00
16278ac81e Give the characters breakout its bricks as collision geometry
`HITTEST`, `XBRICK` and `YBRICK` are gone -- about forty lines that divided
pixels by cell sizes to recover a grid index, tested the ball's leading edge
rather than its box, and could miss a brick clipped at the corner by seven
pixels' worth of ball. In their place: sixty `SOLID` rectangles registered as the
wall is built, `COLLISION 2, BRICKHIT`, and a fifteen-line handler.

The handler reads better than what it replaces because it asks rather than
derives. `RCOLLISION(1, 1)` is which brick -- the id is the array index plus one,
so nothing is looked up. Fields 2, 3 and 4 are the way out and how far, so the
ball is pushed exactly clear instead of being restored to a remembered `OX#`/`OY#`.
Field 7 is which axis to reverse, which `TESTCELL` in the other game computes by
hand from an overlap rectangle.

`MOVEBAL` loses its two brick calls and its position backup. `KILLBR` retires the
rectangle in the same breath as clearing the array element, so the next frame
cannot hit a brick that is no longer drawn.

**A latent defect in the target prescan had to be fixed first, and `RCOLLISION`
is the first name in the language to reach it.** `src/renumber.c` walks a line
character by character looking for `GOTO`, `GOSUB`, `COLLISION` and the rest, and
checked only the character *after* a match -- so `RCOLLISION(1, 1)` found
`COLLISION` at its second character, read the `(1,` that followed as a handler
line number, and refused the whole program with "branch to line 1, which the
program did not number", naming a line that contains no branch at all. It now
requires a word boundary on both sides. The comment there was already right that
the trailing check protects `GOTOX#`; nothing protected `XGOTO#`.
`tests/unnumbered.c` covers all three shapes and TODO.md section 6 item 42
records it.

The game runs ninety seconds headless with no error line and the attract mode
scores 1320, so bricks are being found and broken through the new path.

Chapter 17 is not updated yet -- that is the other half of section 6 item 39, and
it is a bigger edit than this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 11:17:34 -04:00
fc122e5503 Let C call a BASIC function with values it already has
`akbasic_runtime_call_function(obj, name, args, nargs, dest)`. The argument
binding is split out of the call-site handling, so
`akbasic_runtime_user_function()` becomes evaluate-the-leaves and then call the
same code -- and a verb that wants to hand a BASIC function four numbers has
somewhere to start, which it did not before. The only entry point took a parsed
AST call site, so calling a function required having been parsed as an
expression.

Behaviour-preserving: both suites pass unmodified. The one deliberate difference
is that the AST path now evaluates *all* the arguments before binding any of
them, where it used to interleave. That is the safer order and it is what
by-value passing means everywhere else -- interleaved, a later argument could see
an earlier one already in the callee's scope.

**Finding, filed as section 6 item 41: a multi-line `DEF` called outside a
running program does not run its body, and says nothing about it.**

    DEF TRIPLE(N#)
    T# = N# * 3
    RETURN T#
    PRINT TRIPLE(14)

At the REPL that prints "(UNDEFINED STRING REPRESENTATION FOR 0)". From a file
the same function answers 42. The multi-line body runs by spinning a line loop
guarded on `mode == AKBASIC_MODE_RUN`, which is true only of a program running
from a file; in REPL mode the loop is skipped and the result is the caller's
zeroed return slot. The single-expression form has no such loop, and every case
in tests/user_functions.c goes through run_program and is therefore in RUN mode,
which is most of why nobody had seen it.

**The obvious fix is wrong and I tried it.** Widening the guard to
`mode != AKBASIC_MODE_QUIT` makes the interpreter *hang* instead of answering
wrongly -- `akbasic_runtime_process_line_run()` does not advance a REPL-mode
runtime the way the loop assumes, so the environment never comes back. Trading a
silent wrong answer for a lock-up is worse, so it is reverted, the reasoning is
in a comment where the next person will try the same thing, and the fix is filed
rather than guessed at.

That bounds this entry point rather than blocking it: it reaches a multi-line
body while a program is running, which is exactly the case a verb calling a
callback is in. The new test asserts the single-expression form from C and says
in a comment why the multi-line one is not asserted, so the omission is a
statement rather than a gap.

What is still not done is the language half -- no verb takes a function, and
nothing resolves a bare word to a function rather than a label. Item 40 now says
so, and says it wants a verb that needs it rather than speculation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 11:06:45 -04:00
a9059423e9 Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.

**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.

**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.

The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.

Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.

Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.

Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:53:26 -04:00
d3b69ff867 Keep what a program draws, instead of making it a sprite
A drawing lasted exactly one frame. The verbs are immediate, they went to the
back buffer, SDL double-buffers and the frontend never clears -- so the only way
to keep a picture was to capture it with `SSHAPE` and install it as a sprite,
which is what `examples/breakout/sprites/breakout.bas` spends two of its eight
sprites doing. That was TODO.md section 9 item 9.

The drawing verbs now render into a layer texture the frame composites under the
text and the sprites. Draw once; it is there on every frame after.

**Bracketed around the step phase, not around each verb.** One pair of
`SDL_SetRenderTarget` calls a frame instead of one per `DRAW`, and it is also
what makes `SSHAPE` read back what the program has just drawn rather than
whatever the last frame left.

**The layer is transparent where nothing was drawn.** It covers the whole window
and composites underneath, so an opaque one would black out the frame the moment
a program issued a single `DRAW`. And a fresh SDL target texture's contents are
undefined, so it is cleared on creation -- skipping that puts uninitialised
memory under the first frame's text and looks like a driver bug rather than a
missing memset.

**The line editor forced a wrinkle worth naming.** `akbasic_frontend_akgl_pump()`
is called from two places with different answers to "is a render target current":
the frame loop calls it between steps, and the sink's editor calls it from
*inside* a step, borrowing a frame while it waits for a typed line. SDL refuses
to present while a target is current, so the pump ends the layer, presents, and
puts it back only if it was the one that ended it. `akgl_frontend` caught this --
it drives a REPL session, and it failed with "You can't present on a render
target" the first time the brackets went in.

This does not make a drawing *visible* on its own. The text layer still repaints
every row it owns, opaque, every frame, and by default it owns the whole window;
`WINDOW` shrinks it and that half was already fixed. The two together are what a
picture needed, and the tests assert both -- a pixel still there a frame later
with nothing redrawn, and a pixel below a shrunk text area surviving the text
repaint. The second assertion wipes to a non-black colour first, because against
black it could not tell a transparent layer from an opaque one.

The tests found two of their own bugs on the way: `stop_runtime()` was not
tearing the graphics backend down, so re-initialising it dropped a live texture
on the floor; and a first draft called `begin()` before `start_runtime()`, which
re-inits the backend, so the assertion read back off an orphaned render target
and passed while proving nothing.

Chapters 6 and 13 stop saying a drawing has to be redrawn every frame, because it
does not. The batch-boundary tear stays documented -- it bites an `SSHAPE`
capture, which matters much less now that capturing is not the only way to keep a
picture.

Both games still run clean. 111 with akgl, 110 without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:46:10 -04:00
66d8670690 Collide sprites with rectangles that are not sprites
`SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id`
retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and
`DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and
mean *sprite met static geometry*.

**This is the thing eight sprite slots made impossible.** A wall of bricks wants
sixty, so until now a program could only collide with one by doing the
arithmetic itself against its own array -- which is exactly what both breakout
listings do, at about two hundred lines between them. A rectangle costs no sprite
slot.

The id is the **program's own number**, 1 to 64, not a minted handle. That is the
whole trick for "which brick did I hit": the id comes back out again, so a wall
built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken
brick is `SOLID I#`.

`COLLISION 2` was refused with "sprite-to-background collision needs the screen
read back every frame", which was true of the question a C128 asks -- a sprite
against the bitmap's set pixels. `SOLID` gives this interpreter a background made
of rectangles instead, which is the same question in a form it can answer. Same
move `SPRSAV` made when it learned to take an image path.
`AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented
"COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is
separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`.

**There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's
uniform grid keeps its cell heads, cell size and origin in file-scope statics, so
it is one index per process -- and `akgl_collision_world_init()` ends in a
`reset()` that memsets those heads *and* calls
`akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its
own collision world would have destroyed every registration that game had made,
on the first `SOLID` a script ran. So the geometry is indexed by an ordinary
array here and pairs go straight to `akgl_collision_test()`, which needs no
world. At sixty-four rectangles that is the right answer anyway; libakgl's own
numbers put a naive sweep at 0.7% of a frame at sixty-four objects.

**The scan now short-circuits when nothing has moved**, and that is what makes
any of it affordable. Its inputs are the sprites' boxes, which slots are
collidable, and the static geometry; if none changed the answer cannot have. A
frame runs one full scan and 255 cached ones. Eight sprites against sixty-four
rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256
times.

The benchmark was rewritten to say which path it is timing, because with the
cache in place a loop that only calls the scan measures the short circuit and
nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached
at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0%
it cost before any of this work**, with static geometry and contacts added on
top.

`NEW` retires the rectangles, where it cannot undefine a sprite pattern: there
*is* an entry point for this one, so leaving them would be a choice, and the
wrong one -- a rectangle is invisible, so one left behind by a deleted program is
an unexplainable collision in the next. `CLR` leaves them alone.

`tests/sprite_verbs.c` gains the whole second path against the mock and its
`COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2
arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains
the end-to-end version, including a full sixty-four-rectangle wall so the proxy
budget is exercised at its ceiling and the pool has to come back intact, and the
sixty-fifth refused by name.

A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than
`akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:25:35 -04:00
f637292eb7 Let a program say what part of a sprite collides
`SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a
circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's
own argument order, the way RSPRITE and RSPPOS already do, and needs no device
because it answers from interpreter state.

The rectangle is two corners measured from the sprite's top-left, in device
pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with
two spellings for a rectangle is one nobody can write from memory. Omit it and
the shape fits whatever the picture turned out to be, which is what a sprite
loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and
the program never learns what that was.

**A sprite nobody has shaped collides with its whole frame, expansion bits
included, exactly as before.** That is a promise rather than a convenience, and
it has its own test: the same two sprites in the same two places, once with no
SPRHIT and once with a four-pixel box, reporting a collision and then not.

Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE
saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a
reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and
RSPHIT, were grepped against every label in docs/, examples/ and both corpora
first: a bare word is a label here, so a verb and a label share one namespace and
taking a name a checked-in listing already uses would break it silently.

`SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen --
the ghost, the flashing invulnerable player, the pickup already taken. Hiding it
with `SPRITE n, 0` stops it colliding too, and is what you want when it should
not be seen either.

The circle answers the complaint chapter 8 already ships a figure of. That figure
shows two discs whose *boxes* touch at a corner while the artwork is nowhere
near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The
test asserts both halves so the figure's caption stays true.

`tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it,
which is the sorted-table test doing exactly the job it exists for.

Docs: a new section in chapter 8, rows in the verb and function references in
alphabetical order, and chapter 13's "collision is by bounding box" becomes
"collision is by shape" with the addition named. 111 with akgl, 110 without, and
the artwork breakout still runs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:01:33 -04:00
14ec8d984c File the four things this work deliberately left alone
Nothing here is built. Each is written down so the reasoning does not have to be
reconstructed by whoever picks it up.

**§9 item 9: the sprite breakout spends two of its eight sprites on the screen.**
It draws its play field, captures the whole 800x540 region with SSHAPE, and
installs the capture as sprite 2 -- and does the same for the HUD strip as sprite
1. That reads as a silly thing to do. It is not: it is the only thing that works,
and it is working around items 3 and 5 together rather than choosing anything.
Item 3 says the text layer owns every row by default, and that half is now
answerable since WINDOW became reachable. Item 5 is what WINDOW does not fix --
the frontend never clears and SDL double-buffers, so a drawing has to be
re-issued every frame and fit inside one 256-line batch. Breakout's field is
sixty GSHAPE stamps plus two BOXes plus a drawn banner; it does not fit and never
will. A sprite is the one thing the interpreter redraws from its own state for
nothing.

What that costs is now measured rather than asserted: two of eight sprite slots,
which is the root of every design compromise in that game and the reason chapter
18 opens with a budget table; **4.5% of a frame in collision alone**, because
sprite 2's box covers the whole field so every moving sprite overlaps it
permanently and the broad-phase reject can never throw those pairs out -- 211.8
ns a scan against 54.9 for eight sprites that do not overlap, on every one of 256
scans a frame, to collide with the backdrop; and a chapter section that exists
only to teach the workaround.

The fix is somewhere to draw that persists and is not a sprite -- a layer the
sink composites under the text and the sprites, that a program writes once and
the frontend does not discard. Nothing in libakgl 0.8.0 supplies it; there is no
render-to-texture layer and `frame_start` clears. Filed rather than fixed because
it is a design decision about what a frame owns.

**§6 items 38-40**, the follow-ups to the collision integration:

- Finishing the COLLISION/BUMP migration. The proxies are deliberately not
  registered with a partitioner -- a uniform grid over eight of them costs more
  than an all-pairs loop over 28 pairs saves. What is worth recording is the
  *threshold*, so that raising AKBASIC_MAX_SPRITES is a decision made with the
  number in front of it.
- Converting both breakout listings and chapters 17 and 18 to the new verbs.
  About 200 of their 230 collision lines are a sprite against a BASIC array,
  which static collision geometry is what changes. Separate because both chapters
  were rewritten and validated immediately before this work, their examples are
  executed by ctest, and folding a listing rewrite into a library integration
  would make neither reviewable.
- `akbasic_runtime_call_function()`. A verb cannot take a BASIC function today.
  The finding worth keeping is that the hard half already exists at
  src/runtime.c:1031 -- the multi-line DEF path already re-enters the line loop
  from inside expression evaluation -- so this is splitting one argument-binding
  loop, not building a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 09:51:17 -04:00
c18ec17e8f Answer sprite collision through libakgl's narrowphase
`spr_collisions()` computed axis-aligned overlaps itself, because at libakgl
0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a
documented corner-containment defect and the physics backend's `collide` slot
raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto
it.

The mask is bit-identical and every test from the previous commit passes
**unmodified**, which was the gate this stage had to clear -- including the two
that were written to be hard to satisfy. Edge-to-edge is still not a collision,
so a tile-aligned program is unaffected. The cross-shaped overlap is still
reported, which is the one that could have regressed: it is the case
`akgl_collide_rectangles()` gets wrong and the reason the hand-written loop
existed, and `akgl_collision_test()`'s box path gets it right.

What it buys is the contact -- a normal, a penetration depth and a point --
which four comparisons cannot produce. Nothing consumes it yet; that is the next
commit. It is here now because the mask and the contact come out of the same
test, and computing them in two places would be two things to keep in step.

**The first attempt was ten times slower and the benchmark caught it.** Syncing
all eight proxies and running the narrowphase on all twenty-eight pairs measured
984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256
scans a frame -- to produce a mask that was bit-identical and a contact that was
thrown away. Two changes fixed it, and both are what a broad phase *is* rather
than workarounds for a slow library:

- **Reject on the bounding boxes first.** The four comparisons that were always
  here now decide which pairs are worth an exact answer. The narrowphase still
  decides the bit -- the box test only says "maybe", which will matter the moment
  a shape is not the whole frame.
- **Do not sync a proxy that has not moved.** The scan runs at the top of every
  interpreter step and a sprite moves at most once in that time, so almost every
  sync would rewrite a proxy with what it already holds. Compared against the
  last synced rectangle rather than flagged by the verbs, because a host game can
  move a BASIC sprite through the actor registry and a flag would miss that.

Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the
96.3 ns it replaced -- boxes are now built eight times a scan instead of
fifty-six. The number that matters is the new benchmark row for the arrangement
`examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or
4.5% of a frame. That game reaches it because two of its eight sprites are the
screen -- a captured HUD strip and a captured play field -- so the field's box
covers everything and those pairs can never be rejected. Roughly double the old
cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes
either side of it as a bracket.

The eight proxies are claimed once at init and held, so exhaustion of the pool
shared with an embedding host is an initialization failure that names the pool
rather than a collision scan refusing halfway through somebody's game. The shape
is built before the proxy is spawned from it and the acquire sits adjacent to the
initialize, which are two traps libakgl hit itself and documents.

`tests/akgl_backends.c` now tears the sprite backend down between cases. It never
did, and got away with it while init claimed nothing; eight proxies apiece across
twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight.
A host releases what it took, and so does the harness.

Both games run forty seconds headless with no error line. 111 with akgl, 110
without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 09:48:11 -04:00
240e0dd141 Take libakgl 0.8.0, and correct the status range it grew
The pin moves e4aa6a5 -> 149bee0, which is libakgl 0.8.0. Nothing in this
repository changed to accommodate it: both suites pass unmodified, 110 without
akgl and 111 with, and both breakout games run forty seconds headless with no
error line.

**0.8.0 is a whole collision subsystem** -- shapes, pooled proxies, a pluggable
broad phase over a uniform grid, a narrowphase answering with a contact carrying
a normal and a depth, world box queries, and static proxies for geometry that is
not an actor. None of it is called from here yet. This commit is the pull and
nothing else, so that if it has to come out it is one revert of one commit.

**It adds two submodules of its own**, `deps/libccd` and `deps/tg`, so a tree
that updates without `--recursive` configures and then fails compiling libccd.
libakgl's own suite was built and run standalone at RelWithDebInfo before
akbasic was pointed at it: 33/33.

`libakstdlib` and `libakerror` did not move. MAINTENANCE.md requires checking,
and the answer this time is that libakgl 0.8.0 pins exactly what this repository
already pins -- 669b2b3 and 5eaa956 -- so the pairing rule is satisfied without a
bump. Recorded because "we checked and it was already aligned" and "we forgot to
check" look identical in a diff.

**The version floor moves to 0.8.0** with its paragraph, following the
convention in that header of saying what each minor release did and why the
floor moved anyway. Worth knowing for whoever reads it next: `akgl_Actor` grew
fields, so a translation unit compiled against a 0.7 `actor.h` and linked
against 0.8 writes `renderfunc` and `actorData` at the wrong offsets -- and
`src/sprite_akgl.c` writes exactly those two. That is the case the soname cannot
catch and the guard exists for.

**libakgl's reserved status band grew from five codes to six**, gaining
`AKGL_ERR_COLLISION` at `AKGL_ERR_BASE + 5`, so it now owns 256-261 and the
headroom below akbasic's band starts at 262. Four documents said otherwise: the
coordinated range map in MAINTENANCE.md, the comment above the enum in
`include/akbasic/error.h`, the file header of `include/akbasic/akgl.h`, and
chapter 15. That map is the only coordination there is -- nothing enforces a
band boundary at compile time, and the first anybody would know of an overlap is
a status printing under the wrong owner's name in a stack trace.

Also cleaned on the way past: `deps/libakgl/deps/libakstdlib` was showing dirty
in `git status`. It had no local edits -- the checkout was simply one commit
behind the gitlink libakgl records -- so `git submodule update` restored it and
nothing was lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 09:35:22 -04:00
adf51a8044 Test the collision scan, and measure what it costs
`spr_collisions()` had no test. `tests/sprite_verbs.c` drives the collision path
end to end but through a mock backend, so the real overlap arithmetic in
`src/sprite_akgl.c` could have changed what `BUMP(1)` reports for every program
in existence and the suite would still have printed 110/110. That gap is closed
here, before anything touches the arithmetic, so that there is a *before* to
compare an *after* against.

Six cases against the real akgl backend: nothing defined, two sprites
overlapping, edge-to-edge, a hidden sprite, and the x-expand bit doubling the box
that collides rather than only the one that draws. Edge-to-edge earns its place
-- the test is a strict `<`, a tile-aligned program puts sprites there
constantly, and a replacement answering "touching" instead of "overlapping"
would change every one of them silently.

**The seventh is the cross-shaped overlap**, and it is the one to watch. A tall
thin sprite crossing a short wide one overlaps without either rectangle holding a
corner of the other; `akgl_collide_rectangles()` is documented as answering "no"
there, which is why `src/sprite_akgl.c` does the four comparisons itself rather
than calling it. Two further assertions stop that test passing by accident: each
sprite is moved clear along the axis it is supposed to be short on, so a sprite
that came out the wrong size fails rather than quietly reporting an ordinary
overlap.

`tests/collision_perf.c` answers the question nobody had measured. The service
runs at the top of every interpreter *step* and the frontend takes 256 steps per
rendered frame, so a busy program scans up to 256 times a frame over sprites that
have not moved. At RelWithDebInfo, scale 10, best of 5: the scan is 96.3 ns at
eight overlapping sprites and 19.4 ns at none, against a rendered frame of
1.17 ms. **256 scans is 24.7 us, or 2.1% of a frame, in the pathological case,
and 0.42% for a program with no sprites.**

So the per-step cadence stays. It is what makes a collision report describe where
the sprites have just been moved to rather than where they were, and 2% of a
frame in a case no real program reaches is not worth changing when a handler
fires for every program that already works. The numbers and that conclusion are
in `MAINTENANCE.md` so it does not get re-argued.

The benchmark borrows libakgl's `benchutil.h` by include path rather than copying
it, the way the fixture font is already borrowed, and is labelled `perf` so
`ctest -LE perf` can leave it out. It runs at scale 1 in the ordinary suite --
1.2 seconds -- because a benchmark nothing ever builds is a benchmark that rots.

Both suites green: 111 with akgl, 110 without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 09:25:22 -04:00
77a1306987 Rewrite the two game tutorials as instructions rather than commentary
Chapters 17 and 18 read as a code review of a finished listing: they explained
why each decision had been made, walked through the project's own history, and
led with what had once been broken. A reader who wanted to build the game got
the reasoning and had to reconstruct the program.

Both are now step-by-step. Each opens with a picture of the finished game and a
bullet list of the steps, each bullet is a section, and each section states its
goal, shows the code, and says how to check it. Chapter 17 is sixteen steps and
Chapter 18 is thirteen, and the last of each is the assembly: the order of the
file, the full declaration block, and the routines the earlier steps referred to.
Project history is gone -- it belongs in Chapter 14 and in git -- and where a
listing has to do something awkward, the tutorial shows how first and names the
`TODO.md` item that will make it unnecessary second.

**Three defects had no entry anywhere**, which the rewrite found by trying to
state each rule as a rule. §6 item 35: `a - b + c` computes `a - (b + c)`,
because `subtraction()` sits above `addition()` as its own precedence level and
the inner loop eats the `+`. Item 36: only one unparenthesised `AND` or `OR` is
matched, which is item 12's `if`-where-`while` on the one operator pair item 12
did not reach. Item 37: a `GOTO` out of a `FOR` or a `DO` leaks the loop's scope,
so a main loop written that way stops on the thirty-second lost life -- which is
why both games are built out of `LABEL` and `GOTO`, and it is a workaround rather
than a preference. Chapter 3 gains the identifier rule the third trial ran into:
there is no underscore in a name, and the error says `UNKNOWN TOKEN _`.

**`tools/screenshot.c` learned to draw the text layer**, behind a new `text=1`
fence attribute, because Chapter 17's game is characters in the grid and a figure
without that layer is two sprites on black. It opens the bundled font at the size
the standalone frontend uses, so a figure's cell size is the reader's cell size,
and it uses the akgl sink alone rather than a tee so the program's output lands in
the picture instead of on the stdout the caller reads to decide a figure failed.
Both new figures -- `breakout-game.png` and `breakout-game-artwork.png` -- are
generated from listings in the chapters like every other one.

Verified by handing each chapter, alone, to an agent on a much smaller model and
telling it to build the game from the tutorial text with the `examples/` tree off
limits. The first pass scored 3.5 and 3 out of 10 and named what was missing:
routines referred to but never shown, the third level layout, the sprite `DATA`,
the font table, edits to earlier routines that were never marked as edits. Those
are now in. The second pass built a 658-line Chapter 17 game that plays itself
for ninety seconds with the score at 1890 and no error line, and the third built
a 1053-line Chapter 18 game with 61 labels, no invented routines, no gaps found,
and forty seconds clean. 9/10 and 8/10.

One real bug in the new prose, caught in review: Chapter 18's `HITBAR` did not
set the `HIT#` that `BALLPADDLE` reads to decide whether the paddle already
caught the ball. Both suites green in both configurations, `docs_examples` and
`docs_screenshots --check` pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 08:08:23 -04:00
8763da12c1 Stop the listings and their chapters stating fixed defects as fact
You were right that this was still everywhere. The two `.bas` files carried
their workarounds as present-tense statements about the interpreter -- "a name
first created inside a GOSUB costs a value slot that is never handed back",
"writing PAST the terminator of a short row draws nothing at all", "SSHAPE and
GSHAPE ignore the subscript on a string array", "a FOR inside a BEGIN block does
not survive the RETURN" -- and every one of those is now false. A comment that
lies is worse than no comment, and these were the first thing anybody opening
the files would read.

**The characters game takes the geometry fix.** `CW# = 16` becomes `CW# =
RGR(3)`, and the grid comes from `RWINDOW`, so the game fits whatever window and
font the host gives it. That was the one thing in the listing that would break on
a different font, and `RGR(3)` and `RWINDOW` were added to the interpreter
because of it -- leaving the constant in place while Chapter 17 teaches the
function was the inconsistency worth closing. `RGR(1)` still comes first so a
build with no graphics device refuses by naming the device that is missing.

Everything else in both listings keeps its shape and says why. Declaring every
name up front, guarding loops with `GOTO`, six scalars for six brick stamps: none
is forced any more and none costs anything, so they stay, with the comments
marking which rules stand and which are history. The sprites header's five traps
are marked FIXED where they are fixed.

The chapters follow: Chapter 17 Step 2 no longer says "the listing still has
`CW# = 16`", and Chapter 18's trap section is "five things that did not do what
they looked like" with the two that still stand named up front rather than left
to be counted.

Verified: Chapter 17 Step 2's block is once again verbatim from the listing, so
every quoted fragment in both chapters matches its source with no exceptions;
both games run 40 seconds on the SDL frontend with no error line; both suites
green in both configurations; `docs_examples` and `docs_screenshots --check`
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 06:32:48 -04:00
4b5533b370 Assert the log line a failed report leaves behind
A mutation run over src/runtime.c found that deleting the
`LOG_ERROR_WITH_MESSAGE` in `report_and_reraise()` left the whole suite green.
That line *is* the visible half of the "a failed report must not swallow the
program's own error" fix -- without it the secondary failure goes nowhere and
the only evidence is the original error still being the one raised. Nothing
looked at it, so nothing noticed.

`tests/trap_verbs.c` now redirects `akerr_log_method` into a buffer and drives
the path with a sink that refuses every write, which is the only way left to
make reporting fail now that the `TRAP` dispatch no longer allocates. It asserts
the log line and that `errclass` still records the program's own error, and it
kills the mutant.

TODO.md gains a section for what the run found and what it did not: it was cut
off at 551 of 997 mutants after ninety minutes, so 446 are unexamined and a full
run belongs in the release workflow rather than here. The three regions this
work touched were all covered, and the survivors in the skipped-block guard are
named and assessed -- three of the four are equivalent for any program that can
actually be written, and the one that is not would need a test with a loop on
line 1.

Also recorded: src/variable.c at 68.2% with every mutant on the inline-storage
lines killed, and src/runtime_trap.c at 82.5% with no survivors in
`set_error_variables()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 02:17:17 -04:00
6abec6d22d State where a scalar lives in the three chapters that describe the pool
Chapter 13's limits table said 4096 array elements "in total", Chapter 16 said
an instance's fields come out of the same pool and nothing is reclaimed, and
Chapter 14's pool table listed `AKBASIC_MAX_ARRAY_VALUES` with no note about
what does and does not draw from it. All three were written when a scalar drew
from that pool, and all three now understate what a program may do.

Each gains the same two facts in the register it is written in. Chapter 13: a
scalar does not come out of the 4096, so creating one inside a `GOSUB` or a
`FOR` -- the loop counter included -- costs nothing, while a `DIM` inside a
scope does and is not given back. Chapter 16: scalars are the exception, and
"nothing is reclaimed" is what lets a pointer into a record stay sound after its
scope has gone -- which is the reason arrays and structures still spend.
Chapter 14: a row for the variable's own storage, and a paragraph on why the
value pool is the one budget that needs a second sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:32:20 -04:00
3e873b9bb7 Bring the tutorials back in line with the fixed interpreter
Eleven of the thirteen defects these two games found are fixed, and the
chapters that taught around them said things that are no longer true.

Chapter 17: the drawing verbs are no longer invisible, they are covered by a
text layer that `WINDOW` can now shrink; the cell size is `RGR(3)` and the grid
is `RWINDOW`, not a hand-measured constant; a character written past a short
row's end lands. Chapter 18: traps 3 and 4 -- the skipped block that broke its
caller's `RETURN`, and `SSHAPE` ignoring a subscript -- become history rather
than warnings, and Step 3 no longer claims a sprite is the *only* way to put a
picture up, only the one that costs nothing.

**Neither `.bas` listing changes, and both READMEs now say why.** The character
game still writes `CW# = 16` and the artwork game still keeps six brick stamps
in six scalars. What those listings are worth is being what a program written
against those constraints looks like, with comments explaining what each one was
avoiding -- rewriting them to pretend the problems never existed would throw
that away. So Chapter 17 Step 2 shows the `RGR(3)`/`RWINDOW` form and says
plainly that the listing beside it does not use it. That is the one place in
either chapter where a quoted fragment is not verbatim from the game.

Also: the closing pointers now say which entries are struck and which stand, and
the budgets table in Chapter 18 notes that several of those budgets were tighter
when the game was written.

TODO.md section 9 item 3 is updated rather than struck: the `WINDOW` half is
fixed, the default text area owning the whole window is not, and whether that
default is right is a question rather than a defect.

Verified against the fixed interpreter: both games run 45 seconds on the SDL
frontend with no error line and the score climbing; both suites are green in
both configurations; `docs_examples` passes and `docs_screenshots --check`
re-renders all thirteen figures byte-identically; coverage is 95.0% of lines
against the 90 gate, with src/variable.c at 100%; and every other quoted
fragment still appears verbatim in the listing it came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:31:06 -04:00
f64ed56beb Write down that a redraw has to fit inside one batch
"Drawing does not persist across frames; redraw it every frame" was the advice,
and it is not sufficient on its own. The host runs a fixed number of source
lines and then presents, and presenting discards the drawing buffer -- so a run
of drawing verbs longer than one budget is **torn**, not merely transient, and
an `SSHAPE` at the end of it captures only what was issued since the present,
over whatever the frame before left behind.

Measured against the standalone frontend's 256: after synchronising to a jiffy
edge, 220 lines of drawing survive a capture and 250 do not.

There is no fix available that does not change what a host owns -- the budget is
the host's and so is the present -- so this is documentation, in the three
places it belongs. Chapter 13 beside the note it qualifies, with the numbers.
Chapter 6 where `SSHAPE` is introduced, which is where a program meets it.
Chapter 14 from the step loop's side, naming `AKBASIC_FRONTEND_STEPS_PER_FRAME`
and explaining the one thing a script can do about it: `settime()` is called once
a frame, so `TI#` changes on the first step of a batch and nowhere else, and
spinning until it changes is the only frame synchronisation this dialect has.

**Untested, deliberately.** Reproducing it needs the real frontend, a clock and
a timing window, and a test that reproduced it would be reproducing a race.
Recorded in TODO.md rather than left looking like an oversight.

TODO.md section 9 item 5, struck as a documentation outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:23:36 -04:00
64e2027099 Write down that the left operand decides integer or float arithmetic
No behaviour change, by decision. `A# * 0.45` is 0 and `0.45 * A#` is 1.35,
because every operator branches on `self->valuetype` and converts the right
operand to match. It is inherited from the Go reference, a C128 promotes to
float instead, and this interpreter is at least consistent about it -- so a
dialect saying the left operand wins is a defensible position, and changing it
to promotion would alter the result of every mixed expression in every existing
program.

**The defect was that nobody said so.** Chapter 3's "Numbers" did not mention
it, Chapter 13 did not list it among the differences, and nothing fails when a
program gets it wrong -- it computes something else and carries on. The game in
examples/ lost its per-level speed increase to `5.6 + LEVEL# * 0.45` evaluating
to a flat 5.6, and bled velocity out of every bounce through `0 - BLVX%(B#)`
quantising to whole pixels. Both read correctly. Neither produced a diagnostic.

Now said in three places: a section in Chapter 3 with the demonstration and the
two rules that keep a program out of it (put the float on the left, put the
answer somewhere with a `%` on it), a row in Chapter 13 naming it as the
difference from 7.0 most likely to turn a working listing into a quietly wrong
one, and the reasoning on value.h where the operators are declared.

tests/value_arithmetic.c pins it in both directions across multiply and
subtract, with a comment saying it is the documented contract rather than an
accident -- so promotion becomes a decision somebody takes deliberately rather
than a change that could slip in under a passing suite.

TODO.md section 9 item 4, struck as a documentation outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:23:20 -04:00
d65d6e6f26 Accept GRAPHIC CLR and a negative DATA item
Two parse handlers refusing something the documentation promises. Landing
together because they are the same defect twice -- a verb's own argument shape
falling through to a general path that cannot see it -- in one file, found by
one program, and verified in one pass.

**`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md
and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so
the generic arglist path scanned it as a command token and the expression parser
answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as
this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()`
already treats as "drop the saved shapes and go back to text", so both spellings
are one statement and the exec handler is untouched. The documentation was right
all along; nothing in it changes.

**`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans
the source text directly (src/data.c) and always returned the -5 intact. So the
value was right and *reaching* the statement raised -- which, since section 4
settled that `DATA` at run time is a no-op, is what a program does with every
`DATA` line it walks past. A table of coordinates or velocities is full of
negative numbers, which is how a game found it.

The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#`
is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning
what it says because other callers rely on it.

tests/read_data.c covers both mechanisms -- reading a negative item and reaching
the line after it -- with a mixed-sign table and a negative float, since the two
were never the same code path.

TODO.md section 9 items 7 and 8, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:18:53 -04:00
198272e553 Land a character written past a short row's terminator
A grid row is a NUL-terminated string, and `putchar_at()` wrote the character,
advanced and terminated -- so `CHAR 1, 40, 1, "#"` on an otherwise empty row
stored the `#` at column 40 with `text[1][0]` still `'\0'`, and the render loop,
which stops at the terminator, drew nothing at all.

The silent nothing is the trap. The write succeeded, the cursor moved, the
stdout mirror showed the character, and only the window stayed blank -- so the
program looked right everywhere except where it mattered. The documented
truncation on the way *back* is fine and is unaffected.

`putchar_at()` now fills the gap with spaces before storing.

**Padded there rather than in `sink_moveto()`**, which TODO.md proposed: this way
`moveto` stays read-only -- the objection that entry raised against its own
suggestion -- and a row is only ever padded when a character actually arrives.

The pad fills from the terminator rather than replacing it. The buffer is not
cleared between rows, so replacing only the terminator would expose the tail of
whatever longer row used to be there: writing "ABCDEFGHIJ", truncating it to
"ABCX", then writing at column 7 must give "ABCX   Z" and not "ABCX FGZ".
tests/akgl_backends.c asserts all three cases, and the middle one keeps the
truncation pinned.

TODO.md section 6 item 32, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:13:42 -04:00
748fb5f91c Honour a subscript in SSHAPE and GSHAPE
`shape_variable()` took the identifier off the leaf and looked the variable up
without ever evaluating the subscript, and both verbs then addressed element
zero with a literal. So `SSHAPE SH$(2), ...` wrote the handle into `SH$(0)` and
`GSHAPE SH$(2)` stamped whatever was in `SH$(0)`.

Ordinary assignment and `PRINT` honour the subscript, which is what made this
expensive: a program keeping several saved shapes in an array got every one of
them resolving to the same element, silently, and the only symptom was that
every stamp came out as the last shape captured. The Breakout in examples/ keeps
its six brick stamps in six separate scalars for exactly this reason.

`SPRSAV` was the counter-example and is the model -- it evaluates its argument
and handles an array element correctly. The subscript resolution itself is now
shared: `collect_subscripts()` comes out of src/environment.c as
`akbasic_environment_collect_subscripts()`, so a verb taking a variable by name
resolves a subscript the same way assignment does rather than each verb deciding
for itself.

tests/graphics_verbs.c covers TODO.md's reduction -- which used to print
"[SHAPE:0] []" and now prints "[] [SHAPE:0]" -- and the case a program actually
wants: two shapes captured into two elements, each stamped back through its own,
asserted against the device log so a fix that merely made the strings look right
would not pass.

Chapter 18's trap 4 becomes history, and Chapter 6 says an array works.

TODO.md section 9 item 6, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 00:10:24 -04:00