3 Commits
34 ... 10

Author SHA1 Message Date
d715bc0625 Report prescan errors on their source lines
Some checks failed
akbasic CI Build / coverage (push) Failing after 27s
akbasic CI Build / cmake_build (push) Failing after 33s
akbasic CI Build / akgl_build (push) Failing after 18s
akbasic CI Build / sanitizers (push) Failing after 1m7s
akbasic CI Build / mutation_test (push) Failing after 39s
2026-08-05 06:40:14 -04:00
350deb3a45 Merge pull request 'Move BASIC fixtures into the editable language corpus' (#42) from 40 into main
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 4m2s
akbasic CI Build / sanitizers (push) Successful in 4m59s
akbasic CI Build / coverage (push) Successful in 4m35s
akbasic CI Build / akgl_build (push) Successful in 9m17s
akbasic CI Build / mutation_test (push) Successful in 29m15s
Reviewed-on: #42
2026-08-04 16:30:25 -04:00
8a02674af5 Move BASIC fixtures into the editable language corpus
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m34s
akbasic CI Build / coverage (push) Successful in 4m4s
akbasic CI Build / sanitizers (push) Successful in 6m59s
akbasic CI Build / akgl_build (push) Successful in 7m57s
akbasic CI Build / mutation_test (push) Successful in 23m28s
Move every program and expectation out of tests/reference and register the unified tests/language corpus as local cases. Remove the old immutable-corpus protections from build, maintenance, and documentation paths.

Co-authored-by: andrew <andrew@aklabs.net>
2026-08-04 16:22:16 -04:00
161 changed files with 194 additions and 4914 deletions

View File

@@ -17,7 +17,7 @@ jobs:
# Not recursive, deliberately. The top-level build needs # Not recursive, deliberately. The top-level build needs
# deps/libakerror and deps/libakstdlib, via add_subdirectory, and # deps/libakerror and deps/libakstdlib, via add_subdirectory, and
# nothing else: the golden corpus and the Commodore font now live in # nothing else: the golden corpus and the Commodore font now live in
# this repository (tests/reference/ and assets/fonts/), so # this repository (tests/language/ and assets/fonts/), so
# deps/basicinterpret is no longer a build dependency at all. # deps/basicinterpret is no longer a build dependency at all.
# It does *not* need deps/libakgl, which is guarded behind # It does *not* need deps/libakgl, which is guarded behind
# AKBASIC_WITH_AKGL and defaults OFF -- and recursing into it would # AKBASIC_WITH_AKGL and defaults OFF -- and recursing into it would
@@ -62,11 +62,9 @@ jobs:
run: | run: |
cmake -S . -B build cmake -S . -B build
cmake --build build --parallel 2 cmake --build build --parallel 2
# The suite is 78 cases: 41 golden files byte-compared against the Go # The suite is 112 cases: 65 language files with sibling expectations,
# reference's own corpus (checked in at tests/reference/, see its README), # 43 unit tests, 3 embedding examples, and docs_examples.
# 9 local golden cases for verbs the reference never implemented, 25 unit # Some unit tests assert the *correct* contract for known defects (TODO.md
# tests, 2 embedding examples, and 1 known-failing test that asserts the
# *correct* contract for defects carried over from the reference (TODO.md
# section 6). A green run therefore does not mean defect-free -- see # section 6). A green run therefore does not mean defect-free -- see
# AKBASIC_KNOWN_FAILING_TESTS. # AKBASIC_KNOWN_FAILING_TESTS.
# #

View File

@@ -26,7 +26,7 @@ scripting engine for game authors.
| [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet | | [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet |
| [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on | | [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it | | [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
| [`docs/`](docs/README.md) | The language itself: twenty-one chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/`; [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) build the embedding host in `examples/galaga/` | | [`docs/`](docs/README.md) | The language itself: eighteen chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/` |
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively | | `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code | | `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** | | `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |
@@ -57,9 +57,9 @@ repeating where you will see them:
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script. `include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script.
- **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces - **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces
and there is no repo-wide formatter; style conversions get their own commit. and there is no repo-wide formatter; style conversions get their own commit.
- **Do not edit `tests/reference/`.** Those expectations came from the Go implementation and - **Keep `tests/language/` editable.** Its `.bas` programs and sibling `.txt` expectations
are never edited to suit this interpreter. A deliberate divergence goes in are changed together when behavior changes. Record deliberate language decisions in
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`. `TODO.md` or `docs/13-differences.md`.
- **Open an issue for outstanding work; do not add it to `TODO.md`.** - **Open an issue for outstanding work; do not add it to `TODO.md`.**
<https://source.starfort.tech/andrew/akbasic/issues>, or `tea issues create --repo <https://source.starfort.tech/andrew/akbasic/issues>, or `tea issues create --repo
andrew/akbasic`. Name the file and line, the functional consequence, and what closing it would andrew/akbasic`. Name the file and line, the functional consequence, and what closing it would

View File

@@ -268,69 +268,6 @@ if(AKBASIC_BUILD_EXAMPLES)
endforeach() endforeach()
endif() endif()
# The galaga example: a C game on libakgl with the interpreter embedded as its
# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it
# is compiled and run by every AKGL build rather than rotting in a document.
# The asset, script and font paths are baked in so the smoke test can launch
# from any working directory; --assets and --script override them at runtime.
if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
add_executable(akbasic_example_galaga
examples/galaga/main.c
examples/galaga/script.c
examples/galaga/enemies.c
examples/galaga/player.c)
target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
akbasic_instrument(akbasic_example_galaga)
# Ten seconds of scripted play under the headless drivers: the script boots,
# a wave enters and forms, the autoplay pilot shoots at it, and the program
# tears down and exits 0. A tutorial that stops working fails here rather
# than in front of a reader.
_add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay)
_set_tests_properties(example_galaga PROPERTIES TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
# The boundary's round-trip test: links the real script.c and the real
# galaga.bas, and fails the moment the two sides of the interop disagree.
add_executable(akbasic_example_galaga_interop
examples/galaga/interop_test.c
examples/galaga/script.c)
target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga_interop PRIVATE
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas")
target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl
SDL3::SDL3 m)
akbasic_instrument(akbasic_example_galaga_interop)
_add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop)
_set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120)
# Regenerating the game figures in docs/ is a deliberate act, never part of
# a build, for the same reason docs_screenshots is: the PNGs are checked in.
# Wall-clock dt makes each regeneration differ by a few pixels of starfield,
# so expect a binary diff every time this runs; commit one only when the
# content changed on purpose. (docs_galaga_figures, not docs_game_figures:
# the libakgl submodule already owns that target name.)
add_custom_target(docs_galaga_figures
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --frames 40
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-title.png"
--screenshot-frame 30
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --autoplay --frames 370
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-wave.png"
--screenshot-frame 360
DEPENDS akbasic_example_galaga
COMMENT "Regenerating the galaga figures in docs/images"
VERBATIM)
endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests. # Tests.
# #
@@ -511,7 +448,7 @@ if(AKBASIC_WITH_AKGL)
# against. # against.
# #
# **A byte comparison of a rendered PNG is a deliberate bet**, the same bet # **A byte comparison of a rendered PNG is a deliberate bet**, the same bet
# tests/reference/ already makes about golden output: that the dummy video # the language corpus makes about golden output: that the dummy video
# driver and the software renderer are reproducible. They are, run to run and # driver and the software renderer are reproducible. They are, run to run and
# build to build. What is untested is an SDL upgrade that shifts one pixel of # build to build. What is untested is an SDL upgrade that shifts one pixel of
# a diagonal, and the answer to that is to regenerate the figures in the same # a diagonal, and the answer to that is to regenerate the figures in the same
@@ -594,63 +531,20 @@ if(AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
) )
endif() endif()
# The reference's own corpus, byte-compared against the sibling .txt. One CTest # The editable language corpus. One CTest case per .bas so a failure names the
# case per .bas so a failure names the file. # file. Each program is paired with a sibling .txt expectation; see
# tests/language/README.md for the editing rule.
# #
# It used to be driven in place out of deps/basicinterpret, on the reasoning that # The corpus includes programs carried over from the deprecated Go implementation
# copying a submodule's corpus guarantees drift. That reasoning was sound and it # and cases written for this interpreter. Their provenance is useful when
# has been overruled deliberately: the Go dependency is being deprecated, and a # investigating a regression, but it does not make any case immutable.
# build that cannot run its acceptance suite without cloning the implementation
# it replaced is not finished. The copy is byte-identical to
# basicinterpreter@d76162c and tests/reference/README.md records
# where it came from and what the drift now costs.
file(GLOB_RECURSE AKBASIC_GOLDEN_CASES
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests/reference"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/*.bas"
)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
_add_test(
NAME golden_${_name}
COMMAND ${CMAKE_COMMAND}
-DBASIC=$<TARGET_FILE:basic>
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/${_case}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
)
endforeach()
if(AKBASIC_GOLDEN_CASES)
set(AKBASIC_GOLDEN_NAMES)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name})
endforeach()
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
# An AKGL build of `basic` opens a window, and forty-one of them is not what
# anybody running the suite wanted. The dummy driver produces the same stdout,
# which is the only thing a golden case compares.
if(AKBASIC_WITH_AKGL)
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
endif()
endif()
# The local golden corpus, for verbs the reference never implemented.
# #
# Still separate now that the reference's corpus lives in this repository too, # Programs carried over from the deprecated Go implementation and cases written
# and the reason changed rather than went away: tests/reference/ is a *record* of # for this interpreter use the same editable `.bas`/`.txt` contract. Registered
# what the Go implementation did and nothing in it should ever be edited to suit # under local_ so every failure identifies the program that produced it.
# this one, while tests/language/ is ours to change. Registered under local_ so a
# failure says at a glance which of the two it came from -- and so a diff that
# touches tests/reference/ stands out as the thing it is.
# #
# Note what this can and cannot cover. The graphics and sound verbs draw and play # Note what this can and cannot cover. The graphics and sound verbs draw and play
# rather than print, so what a golden file sees of them is their *refusals* and # rather than print, so what a golden file sees of them is their refusals and
# whatever a program can PRINT about the state they changed. The behaviour that # whatever a program can PRINT about the state they changed. The behaviour that
# reaches a device is asserted against tests/mockdevice.h instead. # reaches a device is asserted against tests/mockdevice.h instead.
file(GLOB_RECURSE AKBASIC_LOCAL_CASES file(GLOB_RECURSE AKBASIC_LOCAL_CASES

View File

@@ -88,7 +88,7 @@ source stays readable as documentation of what the original did. Neither is bind
**It is not a build or test dependency.** Both configurations have been configured, built and **It is not a build or test dependency.** Both configurations have been configured, built and
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
`tests/reference/` and its Commodore font at `assets/fonts/`. `tests/language/` and its Commodore font at `assets/fonts/`.
```sh norun ```sh norun
cd deps/basicinterpret cd deps/basicinterpret
@@ -351,15 +351,10 @@ name. That is not cosmetic: `add_executable` creates a dependency's targets even
### The golden corpora ### The golden corpora
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared. `tests/language/` is the editable language corpus. It includes cases carried over from the
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this deprecated Go implementation as well as cases written for this interpreter. Every `.bas` file
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in has a sibling `.txt` expectation, and a new language feature needs that pair as well as unit
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`, not into the tests. Change both deliberately in the same commit; provenance does not make a case immutable.
expectation file. `tests/reference/README.md`
says the same thing at more length.
`tests/language/` is ours and may be changed freely. A new language feature needs a
`.bas`/`.txt` pair there as well as unit tests.
### Mutation-check a fix before you believe it ### Mutation-check a fix before you believe it

View File

@@ -10,7 +10,7 @@ implementation that started from the Java Lox instructions in
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That [craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
project is deprecated. It is vendored here as the behavioural spec to read when a question about project is deprecated. It is vendored here as the behavioural spec to read when a question about
semantics comes up, and its acceptance corpus is checked in at semantics comes up, and its acceptance corpus is checked in at
[`tests/reference/`](tests/reference/README.md) and runs on every build — so nothing about [`tests/language/`](tests/language/README.md) and runs on every build — so nothing about
building or testing this project needs it. building or testing this project needs it.
## Quickstart ## Quickstart
@@ -24,7 +24,7 @@ ctest --test-dir build --output-on-failure
```sh norun ```sh norun
./build/basic # the REPL ./build/basic # the REPL
./build/basic tests/reference/language/functions.bas # run a program ./build/basic tests/language/functions.bas # run a program
``` ```
```basic ```basic
@@ -126,10 +126,10 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
| | | | | |
|---|---| |---|---|
| [`docs/`](docs/README.md) | The guide: twenty-one chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice, and [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) building a C game that embeds the interpreter | | [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice |
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style | | [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence | | [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it | | [`tests/language/README.md`](tests/language/README.md) | The editable language corpus and the rule for changing it |
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`. API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.

37
TODO.md
View File

@@ -38,7 +38,7 @@ What it changes:
- **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs - **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs
a golden file, which is a cost rather than a veto. a golden file, which is a cost rather than a veto.
- **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits. - **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits.
- **`tests/reference/` becomes a regression suite rather than a specification.** Diverging from - **`tests/language/` is the editable language corpus rather than a protected specification.** Diverging from
it is allowed and must be deliberate and recorded — see its README. it is allowed and must be deliberate and recorded — see its README.
What it does **not** change: What it does **not** change:
@@ -306,13 +306,13 @@ to `Println`, which adds another.
**This used to be a hard contract and is now a default.** The Go implementation is deprecated **This used to be a hard contract and is now a default.** The Go implementation is deprecated
and will not be updated, so the two projects are no longer required to match — see §0.1. What and will not be updated, so the two projects are no longer required to match — see §0.1. What
survives is the practical half: these strings and this newline behaviour are what every survives is the practical half: these strings and this newline behaviour are what every
expectation in `tests/reference/` was written against, so changing one means changing golden expectation in `tests/language/` was written against, so changing one means changing the paired
files, and that is worth doing on purpose rather than by accident. A message that reads files, and that is worth doing on purpose rather than by accident. A message that reads
awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit, awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit,
and add a line to §5. and add a line to §5.
Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f` Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f`
(Go's `%f` and C's `%f` both give six decimals — `tests/reference/language/arithmetic/float.txt` (Go's `%f` and C's `%f` both give six decimals — `tests/language/arithmetic/float.txt`
confirms). No reason to change it, which is different from not being allowed to. confirms). No reason to change it, which is different from not being allowed to.
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted** ### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
@@ -375,7 +375,7 @@ Phases 0 through 6 of the original plan are done. The interpreter builds clean u
It *did* reproduce the reference byte for byte, and that claim is retired rather than broken: It *did* reproduce the reference byte for byte, and that claim is retired rather than broken:
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in §0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
`tests/reference/README.md`). Everything else still matches, which is worth knowing but is no `tests/language/README.md`). Everything else still matches, which is worth knowing but is no
longer a gate. longer a gate.
```sh ```sh
@@ -408,17 +408,16 @@ and the only path that existed — `AKBASIC_MODE_RUNSTREAM` reading through the
is the code the README quotes, built by every build and registered as a CTest case so a is the code the README quotes, built by every build and registered as a CTest case so a
signature change breaks the build rather than rotting the document. signature change breaks the build rather than rotting the document.
**The acceptance suite is the reference's own corpus, checked in at `tests/reference/`.** All **The acceptance suite is the editable language corpus, checked in at `tests/language/`.** All
41 `.bas` files are registered as individual CTest cases and byte-compared against their `.txt` 65 `.bas` files are registered as individual CTest cases and compared against their `.txt`
— including the trailing double newline on an error line (§1.8). — including the trailing double newline on an error line (§1.8).
It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
deliberately: the Go dependency is being deprecated, and a build that cannot run its own deliberately: the Go dependency is being deprecated, and a build that cannot run its own
acceptance suite without cloning the implementation it replaced is not finished. The copy is acceptance suite without cloning the implementation it replaced is not finished. The copy is
byte-identical to `basicinterpreter@d76162c`, and `tests/reference/README.md` records the originally byte-identical to `basicinterpreter@d76162c`, and `tests/language/README.md` records the
provenance, the cost of the drift nobody is watching for now, and the rule that those provenance and the rule that programs and expectations are edited together deliberately.
expectations are never edited to suit this interpreter.
**Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked **Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked
rather than assumed — both configurations were configured, built and run from scratch with the rather than assumed — both configurations were configured, built and run from scratch with the
@@ -1026,7 +1025,7 @@ deviations from the reference's *program*: `main.go` and the SDL half of
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable **What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
did exactly that; see `tests/reference/README.md`. did exactly that; see `tests/language/examples/strreverse.bas`.
### Deviations in statement separation ### Deviations in statement separation
@@ -1391,10 +1390,10 @@ deviations from the reference's *program*: `main.go` and the SDL half of
**This one moved a golden file.** A line with no number used to be filed under the loader's **This one moved a golden file.** A line with no number used to be filed under the loader's
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
silently lost the first, and a *blank* line erased whatever preceded it. The reference does silently lost the first, and a *blank* line erased whatever preceded it. The reference does
the same, and `tests/reference/language/arithmetic/integer.bas` is the proof: four `PRINT` the same, and `tests/language/arithmetic/integer.bas` is the proof: four `PRINT`
statements, an expectation with three values, and a trailing blank line that erased statements, an expectation with three values, and a trailing blank line that erased
`40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and `40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and
`tests/reference/README.md` records it. `tests/language/README.md` records it.
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
@@ -1586,9 +1585,9 @@ be reproduced before it can be fixed.
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.** **It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
to `SOURCE$` in `tests/reference/`, the expectation is byte-for-byte unchanged — the program to `SOURCE$` in `tests/language/examples/strreverse.bas`, the expectation is byte-for-byte unchanged — the program
still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in
`tests/reference/README.md`'s divergence table, which this is the first entry in. `tests/language/README.md` records the corpus editing rule.
This item sat parked because the corpus was the acceptance contract and lived in a submodule This item sat parked because the corpus was the acceptance contract and lived in a submodule
this repository could not edit. Both premises are gone: the corpus is checked in, and this repository could not edit. Both premises are gone: the corpus is checked in, and
@@ -2232,10 +2231,10 @@ update --init --recursive` gets them.
## 8. Status ## 8. Status
**The port is done.** The C interpreter passes the Go reference's entire corpus and passes clean **The port is done.** The C interpreter passes the language corpus and passes clean
under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the
requirement; two cases have diverged on purpose since, and requirement; two cases have diverged on purpose since, and
`tests/reference/README.md` lists them. `tests/language/README.md` lists them.
| Gate | Result | | Gate | Result |
|---|---| |---|---|
@@ -2243,7 +2242,7 @@ requirement; two cases have diverged on purpose since, and
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips | | `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips |
| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention | | `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention |
| `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half | | `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half |
| Golden corpus | 41/41 byte-exact from `tests/reference/` — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output | | Language corpus | 65/65 paired expectations — **and 65/65 again through the SDL binary**, which is most of what proves the frontend changes no output |
| ASan + UBSan | 112/112 | | ASan + UBSan | 112/112 |
| Line coverage | 94.1% (7227/7681) — above the 90% gate | | Line coverage | 94.1% (7227/7681) — above the 90% gate |
| Function coverage | 97.9% (474/484) | | Function coverage | 97.9% (474/484) |
@@ -2553,7 +2552,7 @@ What remains, in priority order:
this whole arrangement exists against. this whole arrangement exists against.
The byte comparison is a deliberate bet that the dummy driver and the software renderer The byte comparison is a deliberate bet that the dummy driver and the software renderer
are reproducible, which is the same bet `tests/reference/` already makes about golden are reproducible, which is the same bet `tests/language/` makes about golden
output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade
that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same
commit as the bump** — do not weaken the test to a size check, which would pass for every commit as the bump** — do not weaken the test to a size check, which would pass for every
@@ -2636,7 +2635,7 @@ reduced against `build/basic`, the stdio build, unless it says otherwise.
**It is narrower than it looks, and the golden corpus is why.** The first attempt **It is narrower than it looks, and the golden corpus is why.** The first attempt
released the scope on *any* skip, which broke released the scope on *any* skip, which broke
`tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero- `tests/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero-
iteration `FOR` skips its body by the same mechanism, and there the orphan is iteration `FOR` skips its body by the same mechanism, and there the orphan is
load-bearing -- it is what absorbs the inner `NEXT` so the outer one still finds its load-bearing -- it is what absorbs the inner `NEXT` so the outer one still finds its
`FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the `FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the

View File

@@ -100,50 +100,6 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
dies when the body pops — silently, with the script reading it correctly right up until dies when the body pops — silently, with the script reading it correctly right up until
it stops. it stops.
## Calling a function every frame
`akbasic_runtime_call_function()` calls a `DEF` by name with values you already
hold — the entry point a game loop wants. A host that calls it repeatedly signs
up for three rules the one-shot examples never meet:
```c wrap=hostcalls
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "THINK", argp, 1, &result));
/* ...consume the result... */
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
```
1. **Reset the value scratch after every call, once the result is consumed.**
Each call parks its result in the caller environment's per-line scratch
(`AKBASIC_MAX_VALUES` slots), and a host calling in a loop never crosses the
line boundary that would reset it. Skip the `akbasic_environment_zero()` and
the pool drains — measured at under two frames of forty calls — after which
every call fails with `Maximum values per line reached`. The reset also
invalidates `result`, which is why it comes after the consumption.
2. **Force RUN mode once after the boot run.** A multi-line `DEF` body only
runs while the runtime is in RUN mode, and by the time a host can call, the
program that filed the definitions has ended. One
`akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)` after
`akbasic_runtime_run()` makes the bodies run, and the mode stays put because
nothing steps the runtime between calls. Issue #8 tracks making this
unnecessary.
3. **Revive after a script error, deliberately.** A BASIC-level error inside a
called body reports through the sink, answers a stale value, and latches:
the runtime leaves RUN mode and every later call does nothing. When your
policy is to absorb the error and keep calling — a game marking one actor
dumb rather than killing the frame — the revival is two calls:
`akbasic_runtime_clear_error()`, then `akbasic_runtime_set_mode(RUN)` again.
The latch is deliberate for *programs* — the first error ends a run, once,
with one line — so nothing clears it for you.
Do not pass structures as per-frame arguments. A structure or pointer parameter
spends a value-pool slot on every call and the pool never reclaims, so the
interface dies after about a thousand calls — issue #36 has the measurements.
Bind the instance once with `akbasic_host_bind()` and point it at each object
with `akbasic_host_rebind()` ([Chapter 16](16-structures.md)), which spends
nothing per call. The GALAGA tutorial ([Chapters 20](20-tutorial-galaga.md)
and [21](21-tutorial-galaga-enemies.md)) is this whole recipe as a working
game, forty calls a frame.
## Where the output goes ## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus `PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus

View File

@@ -743,8 +743,8 @@ way to see that something pushed a scope and never popped it.
```sh norun ```sh norun
ctest --test-dir build --output-on-failure -R for_next # one unit test ctest --test-dir build --output-on-failure -R for_next # one unit test
ctest --test-dir build --output-on-failure -R golden_ # the reference corpus ctest --test-dir build --output-on-failure -R local_ # the language corpus
./build/basic tests/reference/language/functions.bas | diff - tests/reference/language/functions.txt ./build/basic tests/language/functions.bas | diff - tests/language/functions.txt
./tests/docs_examples.sh --root . --basic ./build/basic \ ./tests/docs_examples.sh --root . --basic ./build/basic \
--cflags-file build/docs_cflags.txt docs/14-architecture.md --cflags-file build/docs_cflags.txt docs/14-architecture.md
``` ```

View File

@@ -50,7 +50,7 @@ nothing would say which type it is.
``` ```
```output ```output
? 40 : PARSE ERROR TYPE POINT: POINT is a reserved word and cannot name a type ? 10 : PARSE ERROR TYPE POINT: POINT is a reserved word and cannot name a type
``` ```

View File

@@ -685,9 +685,9 @@ seconds asks for fifty frames a second.
### Why `GOTO` rather than `DO ... LOOP` ### Why `GOTO` rather than `DO ... LOOP`
A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO` A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO`
that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 12 that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32
scopes, so a game that leaves its main loop once per lost life stops on the scopes, so a game that leaves its main loop once per lost life stops on the
twelfth one: thirty-second one:
```basic ```basic
N# = 0 N# = 0
@@ -700,7 +700,7 @@ PRINT "SURVIVED " + N#
``` ```
```output ```output
? 3 : PARSE ERROR Environment pool exhausted at line 3 (12 in use) ? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
``` ```

View File

@@ -1,849 +0,0 @@
# 20. Tutorial: GALAGA — a C engine with a BASIC brain
This chapter and [Chapter 21](21-tutorial-galaga-enemies.md) build a GALAGA-style
fixed shooter from an empty file. The engine — window, starfield, bullets,
collision, score, screens — is C on libakgl. The enemies think in BASIC: one
script of `DEF` functions is called once per enemy per frame, and it reads and
writes the engine's own structures with no marshalling in either direction.
This chapter builds the engine and proves the boundary works; the next one
fills in the data structures and the AI.
The split is the point. Everything mechanical stays compiled, and everything an
enemy *decides* is a text file you can edit and re-run without rebuilding. It is
an academic exercise in *how* such an embed is done, not a claim that it is the
best way to write a GALAGA.
This is what the two chapters build:
![A full wave: four green bosses, two rows of butterflies, bees still streaming into the grid, the player firing](images/galaga-wave.png)
The finished program is [`examples/galaga/`](../examples/galaga/): four C files,
one `galaga.bas`, and the assets. You do not need it to follow along, but it is
the same program assembled.
```sh norun
$ cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
$ cmake --build build-akgl --target akbasic_example_galaga
$ ./build-akgl/akbasic_example_galaga
```
| Key | Does |
|---|---|
| left / right | move the ship |
| space | fire — two shots on screen at a time, the classic rule |
| return | choose a menu entry |
## What you will do
- **[Step 1](#step-1-open-a-window)** — open a window, in the one startup order
that works
- **[Step 2](#step-2-scatter-a-starfield)** — scatter a starfield and scroll it,
with no parallax machinery at all
- **[Step 3](#step-3-put-a-ship-on-screen)** — put a ship on screen from a
sprite and a character file, and drive it from the keyboard
- **[Step 4](#step-4-shots-and-collision)** — spawn shots from the actor heap
and collide them by hand
- **[Step 5](#step-5-boot-the-interpreter)** — link the interpreter in, load a
script of definitions, and call one from C
- **[Step 6](#step-6-the-update-hook)** — replace an actor's update hook so its
every frame is a BASIC call
- **[Step 7](#step-7-first-light)** — watch one enemy move under BASIC control,
and read the same numbers from both sides
- **[Step 8](#step-8-screens)** — add the title, game over and victory screens
- **[Step 9](#step-9-run-it-headless)** — run the whole game headless, so CI can
play it every night
Each step compiles and runs. The C fragments quote the finished example; the
file layout there — `main.c` for the harness, `script.c` for the boundary,
`enemies.c` and `player.c` for the actors — is a good one to copy.
---
## Step 1: Open a window
**Goal: a black window with a title, from the canonical startup order.**
libakgl has one startup sequence that works, documented at the top of its
`include/akgl/game.h` and walked through in its own tutorial (libakgl
docs/20-tutorial-sidescroller.md). The order matters twice: the screen
properties are read by the renderer, so they must be set before it exists, and
`akgl_game_init()` does **not** install a physics backend, so the application
must.
```c wrap=galagatypes requires=akgl
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
"akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
"1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
"net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
PASS(errctx, akgl_set_property("game.screenheight", "960"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
1280,
960,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = 1280.0f;
akgl_camera->h = 960.0f;
PASS(errctx, akgl_physics_init_null(akgl_physics));
SUCCEED_RETURN(errctx);
}
```
Three of those lines deserve their reasons.
**The view is 1280x960 because the artwork is ~100 pixels wide.** libakgl draws
a sprite at the sprite's own size — `akgl_Actor.scale` is overwritten every
frame, so there is no way to draw one smaller (libakgl docs/12-actors.md) — and
a ten-column formation of 100-pixel ships needs 1120 pixels plus margins. The
view is sized to the art rather than the art resized to a view.
**`akgl_physics_init_null()` is not optional.** Skip it and the first
`akgl_game_update()` calls through a NULL `simulate` pointer. Null physics
accepts every call and moves nothing, which is exactly right here: whatever
writes `x` and `y` directly is the mover, and in this game that will be BASIC.
**Error handling is the house protocol.** Every function returns
`akerr_ErrorContext *`, `PASS` propagates, `ATTEMPT`/`CATCH`/`CLEANUP` brackets
anything that must unwind. libakgl's docs/04-errors.md teaches it; this chapter
just uses it, with two rules that keep the fragments compiling: **`CATCH` is
only legal inside an `ATTEMPT` block, and `PASS` everywhere else** — swap them
and the compiler objects about a stray `break` — and `main()` alone ends its
block with `FINISH_NORETURN(errctx)` instead of `FINISH`, because `FINISH`
expands a `return` of the context that an `int`-returning function cannot
compile:
```c wrap=galagatypes requires=akgl
static int FAILED = 0;
int main(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
(void)argc; (void)argv;
ATTEMPT {
/* CATCH each stage in order: startup, assets, the script boot,
* the spawns, then the frame loop. */
} CLEANUP {
/* ...teardown, every call wrapped in IGNORE()... */
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run");
/* Set a flag rather than returning: leaving a HANDLE block early
* skips FINISH's release and leaks the context's pool slot. */
FAILED = 1;
} FINISH_NORETURN(errctx);
return FAILED;
}
```
The status codes this game raises are `AKERR_NULLPOINTER`,
`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL`
and `AKGL_ERR_HEAP` — there is no code this tutorial invents.
The includes the engine files draw on, so nothing later has to be guessed —
the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not
`SDL3/SDL_ttf.h`):
```c wrap=galagatypes requires=akgl
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include <akgl/util.h>
```
The frame loop is the standard bracket, with one addition you will meet in
Step 6 — for now, events in, world drawn, frame out:
```c wrap=galagahost requires=akgl
while ( SDL_PollEvent(&event) == true ) {
CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
CATCH(errctx, akgl_renderer->frame_start(akgl_renderer));
CATCH(errctx, akgl_game_update(NULL));
CATCH(errctx, akgl_renderer->frame_end(akgl_renderer));
```
`akgl_game_update(NULL)` is update-every-actor, step-the-physics,
draw-the-world. It neither clears nor presents; the `frame_start` and
`frame_end` calls own that.
## Step 2: Scatter a starfield
**Goal: a scrolling two-depth starfield, from an array and one draw call.**
No parallax facility exists in libakgl and none is needed. A fixed array of
stars, advanced per frame and drawn with `akgl_draw_point()` between
`frame_start` and `akgl_game_update()`, is the whole feature. Two speed bands
give the depth for free — the slow band reads as far away:
```c wrap=galagatypes requires=akgl
#define GALAGA_STARS 96
static struct
{
float x;
float y;
float speed;
Uint8 bright;
} STARS[GALAGA_STARS];
static akerr_ErrorContext *starfield_draw(float dt)
{
SDL_Color color = { 255, 255, 255, 255 };
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < GALAGA_STARS; i++ ) {
STARS[i].y += STARS[i].speed * dt;
if ( STARS[i].y > 960.0f ) {
STARS[i].y -= 960.0f;
}
color.r = STARS[i].bright;
color.g = STARS[i].bright;
color.b = STARS[i].bright;
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
}
SUCCEED_RETURN(errctx);
}
```
Seed the array once at startup — even indexes slow and dim (speed 40, bright
110), odd indexes fast and bright (speed 110, bright 220) — and the effect is
done. A point is exactly one pixel (libakgl docs/09-drawing.md).
## Step 3: Put a ship on screen
**Goal: a player actor, drawn from a character file, moving on key input.**
The art is Kenney's Space Shooter pack, CC0, used byte for byte — see
[`examples/galaga/assets/art/PROVENANCE.md`](../examples/galaga/assets/art/PROVENANCE.md)
for what each file is. An actor gets its looks from a **character**, which maps
actor state words to **sprites** (libakgl docs/10 and 12). Both are JSON; load
sprites first, because a character names its sprites and a character loaded
first fails on the first name it cannot find.
These are the names, so the loading lists and every
`akgl_actor_set_character()` call in both chapters agree — each `sprite_*.json`
and `character_*.json` lives in `assets/`:
| Character | Sprite(s) it maps | Worn by |
|---|---|---|
| `galaga_player` | `galaga_player` | the ship |
| `galaga_bee` | `galaga_bee` | bees |
| `galaga_butterfly` | `galaga_butterfly` | butterflies |
| `galaga_boss` | `galaga_boss`, and `galaga_boss_hurt` on state bit 13 | bosses |
| `galaga_playershot` | `galaga_playershot` | the ship's shots |
| `galaga_enemyshot` | `galaga_enemyshot` | enemy shots |
| `galaga_boom` | `galaga_boom` | explosions |
The spawn is four decisions after the two boilerplate calls:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *galaga_player_spawn(void)
{
akgl_Actor *player = NULL;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_actor(&player));
PASS(errctx, akgl_actor_initialize(player, "player"));
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
/* AFTER initialize: it resets all seven hooks. */
player->updatefunc = &player_update;
player->movement_controls_face = false;
player->state = AKGL_ACTOR_STATE_ALIVE;
player->visible = true;
player->x = 590.0f;
player->y = 860.0f;
galaga_game.player = player;
SUCCEED_RETURN(errctx);
}
```
Each of the four lines under the comment closes a trap:
- **`updatefunc` after `akgl_actor_initialize()`**, never before — initialize
installs all seven default hooks, and a hook set first is a hook reset.
- **`movement_controls_face = false`.** The default facing logic edits the
state word, a character mapping matches the **whole** word, and an actor
whose state matches no mapping is *silently not drawn*. Nothing here moves by
state bits, so facing stays out of the word entirely.
- **`state = AKGL_ACTOR_STATE_ALIVE`** — the word the character mapping names.
- **`visible = true`.** `akgl_actor_initialize()` does not raise it. In a
tilemap game the map loader copies visibility from map data; there is no map
here, so an actor that skips this line exists, moves, fires and collides —
invisibly. This one line cost this example its first screenshot.
Input goes through a control map: push a control per key with handlers that set
flags, and let the actor's update hook read the flags. A handler receives the
map's target actor and the event, and returns through the error protocol like
everything else — this pair is the whole pattern, repeated per key:
```c wrap=galagagame requires=akgl
static bool MOVELEFT = false;
akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
MOVELEFT = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
MOVELEFT = false;
SUCCEED_RETURN(errctx);
}
```
(The example keeps the flags in its `galaga_Game` struct rather than statics;
either works.) The bindings themselves are pushes onto map 0:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *galaga_player_controls(void)
{
akgl_Control control;
PREPARE_ERROR(errctx);
memset(&control, 0, sizeof(control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &left_on;
control.handler_off = &left_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_RIGHT;
control.handler_on = &right_on;
control.handler_off = &right_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_SPACE;
control.handler_on = &fire_on;
control.handler_off = &fire_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
akgl_controlmaps[0].target = galaga_game.player;
SUCCEED_RETURN(errctx);
}
```
Hand **every** polled event to `akgl_controller_handle_event()` — one that no
control binds is not an error, it is a call that did nothing.
## Step 4: Shots and collision
**Goal: bullets that fly, hit, and give their actor slot back.**
Bullets and collision are C forever — they are engine, not behavior. A shot is
an actor from the same 64-slot heap pool, with its own tiny update hook: move,
test, release.
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
{
SDL_FRect mine;
SDL_FRect theirs;
bool hit = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
obj->y -= 900.0f * galaga_game.dt;
if ( obj->y < -60.0f ) {
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
shot_box(obj, &mine);
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] == NULL ) {
continue;
}
enemy_box(galaga_enemy_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( !hit ) {
continue;
}
galaga_enemies[i].hp -= 1;
if ( galaga_enemies[i].hp <= 0 ) {
PASS(errctx, kill_enemy(i));
}
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
SUCCEED_RETURN(errctx);
}
```
Four conventions worth keeping:
- **`akgl_collide_rectangles()` is the whole collision system.** At most 2
shots x 40 enemies of axis-aligned tests per frame is noise; the full
`akgl_CollisionWorld` machinery earns its keep on tilemaps, not here. The
`shot_box`/`enemy_box` helpers inset each box from the artwork's rectangle,
because the PNGs carry transparent margin that should not kill anybody.
- **Releasing is despawning.** `akgl_heap_release_actor()` unregisters the
actor and stops it drawing; releasing mid-sweep is safe because
`akgl_game_update()` re-reads each slot's refcount as it goes.
- **Names carry a serial** — `pshot17`, not `pshot1` reused — because the actor
registry is keyed by name, and two live actors with one name is a fight.
- **Spawn caps are C-side refusals.** Two player shots, eight enemy shots; the
spawn functions simply decline past the cap.
Give the enemy shots the same shape falling downward, and the ship a sweep over
both — `examples/galaga/player.c` has all three loops.
Explosions are the fourth actor kind, and they carry the one place this game
*absorbs* an error instead of propagating it. `HANDLE` names the status it
forgives; everything else still travels:
```c wrap=galagagame requires=akgl
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
static uint32_t BOOM_SERIAL = 0;
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
{
ptrdiff_t slot = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
slot = obj - akgl_heap_actors;
BOOM_TTL[slot] -= galaga_game.dt;
if ( BOOM_TTL[slot] <= 0.0f ) {
PASS(errctx, akgl_heap_release_actor(obj));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
{
akgl_Actor *boom = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_actor(&boom));
BOOM_SERIAL += 1;
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
CATCH(errctx, akgl_actor_initialize(boom, name));
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
boom->updatefunc = &boom_update;
boom->movement_controls_face = false;
boom->state = AKGL_ACTOR_STATE_ALIVE;
boom->visible = true;
boom->x = x;
boom->y = y;
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKGL_ERR_HEAP) {
/* Explosions are decoration. When the heap is momentarily full the
* right outcome is no explosion, not a dead frame. */
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
## Step 5: Boot the interpreter
**Goal: the engine calls a BASIC function and prints its answer.**
Everything so far was libakgl. Now link the interpreter into the same
executable. The whole CMake recipe, inside an akbasic checkout with
`AKBASIC_WITH_AKGL=ON`:
```cmake
add_executable(mygalaga
main.c
script.c
enemies.c
player.c)
target_compile_options(mygalaga PRIVATE -Wall -Wextra)
target_compile_definitions(mygalaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(mygalaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
```
The three baked-in paths are what let the program launch from any working
directory; `--assets` and `--script` flags can override them at runtime.
Link `akbasic` — the interpreter only. Not `akbasic_akgl` (the device backends
that let a script draw), and not `akbasic_frontend` (the standalone program's
host). This game lends the script **no devices at all**: the scripts compute,
the engine draws, and a script that tries `SPRITE` is refused by name. That
refusal is enforced by the interpreter, not by convention —
[Chapter 10](10-embedding.md) explains the device-lending model this game
declines to use.
The boot is the embedding host from Chapter 10, adapted to a script that only
defines. Keep every line that touches the interpreter in one file — the
example's `script.c` — so the boundary stays a place rather than a habit. That
file's interpreter-facing includes and statics, exactly:
```c wrap=galagatypes requires=akgl
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
/* Static because an akbasic_Runtime is far too big for a stack frame --
* 2.40 MiB on this branch. */
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static char SOURCE[16384];
```
The boot itself:
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_error_register());
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
```
Two of those lines are the ones a first embedding gets wrong.
**A "no top level code" script still has to run once.** The script is nothing
but `DEF` blocks and a final `END`, and executing the `DEF` statements is what
files the functions. The run is bounded — a script that is all definitions has
no business taking more than a few steps per line, and an accidental loop at
boot should be a diagnosis, not a hang.
**The `set_mode` after the run is load-bearing.** The program has now ended and
the runtime sits in QUIT mode, where a multi-line `DEF` called from the host
returns a silent zero. Forcing the mode back to RUN makes the bodies run, and it
stays put because nothing here ever steps the runtime again. Issue #8 tracks
making this workaround unnecessary.
`PRINT` inside the script goes through the stdio sink and lands on stdout —
that is the script's debug channel for the rest of both chapters.
Prove the wiring with one function. Put this in the script:
```basic
DEF ADDEM(A#, B#) = A# + B#
END
```
And call it from C, with values you already have:
```c wrap=galagacalls requires=akgl
memset(&args[0], 0, sizeof(args[0]));
memset(&args[1], 0, sizeof(args[1]));
args[0].valuetype = AKBASIC_TYPE_INTEGER;
args[0].intval = 17;
args[1].valuetype = AKBASIC_TYPE_INTEGER;
args[1].intval = 25;
argp[0] = &args[0];
argp[1] = &args[1];
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result));
printf("ADDEM(17, 25) = %lld\n", (long long)result->intval);
```
```text
ADDEM(17, 25) = 42
```
`akbasic_runtime_call_function()` is the host's entry point: a name and
already-evaluated values in, the function's result out. The engine refuses to
start when the script will not boot — a game whose enemies cannot think is not
a game missing a feature, it is a game that does not run.
## Step 6: The update hook
**Goal: one actor whose every frame is a BASIC call.**
`akgl_game_update()` calls each live actor's `updatefunc` exactly once per
frame. Replacing that pointer is the whole integration: the actor's frame *is*
a script call.
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
{
galaga_Enemy *enemy = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
enemy = (galaga_Enemy *)obj->actorData;
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
enemy->rnd = galaga_random();
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
if ( enemy->fire != 0 ) {
PASS(errctx, enemy_fire(enemy, obj));
}
SUCCEED_RETURN(errctx);
}
```
The hook's body is a protocol, and `galaga_script_update_enemy()` is its
middle: **rebind, call, recover, reset.**
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
memset(&dtval, 0, sizeof(dtval));
dtval.valuetype = AKBASIC_TYPE_FLOAT;
dtval.floatval = (double)dt;
argp[0] = &dtval;
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result));
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
```
`SELF@` and `ACTOR@` are **host bindings** — the enemy's record and the
engine's live actor, shared with the script as structures it can read and
write directly. [Chapter 21](21-tutorial-galaga-enemies.md) builds them; for
this chapter, know that `akbasic_host_rebind()` points an existing binding at
a different instance, which is how forty enemies share one script: one name,
rebound per enemy, rather than forty names.
**The `akbasic_environment_zero()` after every call is load-bearing.** Each
call parks its result in the caller environment's per-line value scratch, and a
host calling in a loop never crosses the line boundary that would reset it.
Without this line the scratch drains in under two frames of a 40-enemy wave and
every later call fails with `Maximum values per line reached`. Chapter 10's
["Calling a function every frame"](10-embedding.md#calling-a-function-every-frame)
section is the rule's home.
## Step 7: First light
**Goal: a C actor moving under BASIC control, and proof it is one memory.**
Before any real AI, the smallest demonstration. One enemy, one function, a sine
drift written entirely in BASIC through the actor binding:
```basic
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200
ACTOR@.Y% = 300.0
PRINT "BASIC SEES X = " + ACTOR@.X%
RETURN 0
END
```
Spawn one enemy with the hook from Step 6, and have the engine print the same
actor's position each frame from C:
```c wrap=galagahost requires=akgl
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
```
```text
BASIC SEES X = 593.191094
INFO: C SEES X = 593.191094
BASIC SEES X = 596.378593
INFO: C SEES X = 596.378593
```
Same numbers, one memory. The script wrote `ACTOR@.X%`; the renderer read
`akgl_Actor.x`; nothing copied anything anywhere. The ship swings in a slow
arc, and the whole architecture is visible in that one motion: C owns the
frame, BASIC owns the decision, and the actor is the same bytes to both.
## Step 8: Screens
**Goal: title, playing, game over, victory — a state machine around the loop.**
The screens are libakgl's UI layer, in the three-state pattern of its uidemo
example (libakgl docs/22-ui.md). A `galaga_Screen` enum, one `declare_*()`
function per screen, and the UI bracket between `akgl_game_update()` and
`frame_end` — exactly where the frame contract puts it:
```c wrap=galagahost requires=akgl
CATCH(errctx, akgl_ui_frame_begin());
switch ( galaga_game.screen ) {
case GALAGA_SCREEN_TITLE:
CATCH(errctx, declare_title());
break;
case GALAGA_SCREEN_PLAY:
CATCH(errctx, declare_play());
break;
case GALAGA_SCREEN_GAMEOVER:
case GALAGA_SCREEN_VICTORY:
CATCH(errctx, declare_end());
break;
}
CATCH(errctx, akgl_ui_frame_end(akgl_renderer));
```
The playing screen is two `akgl_ui_label()` calls — a widget call per label,
not a struct — formatted into `static` buffers, because the UI borrows label
text until `frame_end` and a local buffer would be dangling by the time it
draws:
```c wrap=galagagame requires=akgl
static char HUD_SCORE[64];
static char HUD_LIVES[64];
static akerr_ErrorContext *declare_play(void)
{
int count = 0;
PREPARE_ERROR(errctx);
PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE),
"SCORE %06d", galaga_game.score));
PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES),
"LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave));
PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
SUCCEED_RETURN(errctx);
}
```
The title and end screens are an `akgl_ui_menu()` at the center, fed an
`akgl_UiMenu` that lives in a `static` for the same borrowing reason. The
struct is an id, the item strings, a count, the selected index, the
`activated` output flag, and a style (`NULL` for the default):
```c wrap=galagatypes requires=akgl
static akgl_UiMenu TITLE_MENU = {
"titlemenu", { "START", "QUIT" }, 2, 0, false, NULL
};
static akerr_ErrorContext *declare_title(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_ui_menu(&TITLE_MENU));
SUCCEED_RETURN(errctx);
}
```
Route events to the menu with
`akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed)` — the menu for
whichever screen is up, a `bool` out-parameter reporting whether the event was
taken. Up and down move `selected`, return sets `activated`.
The big **GALAGA** headline is direct text rather than a label:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *draw_banner(char *text)
{
SDL_Color ink = { 235, 235, 235, 255 };
TTF_Font *font = NULL;
int w = 0;
int h = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
PASS(errctx, akgl_text_measure(font, text, &w, &h));
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0, (1280 - w) / 2, 280));
SUCCEED_RETURN(errctx);
}
```
The menu owns `AKGL_UI_ANCHOR_CENTER`, a label anchored there disappears
behind it, and there is no top-center anchor — so the headline measures itself
and draws at a coordinate, before the UI bracket so the menu still paints over
it if the two ever meet.
![The title screen: the banner, the menu, the starfield](images/galaga-title.png)
Screen transitions are three rules read after the world updates: lives spent is
GAME OVER, an empty wave is VICTORY, and a menu activation either restarts or
quits. The menu never clears its own `activated` flag — the state machine that
acts on it does.
## Step 9: Run it headless
**Goal: the same game, playable by a script, in CI every night.**
The example takes five flags, in the pattern of libakgl's sidescroller:
```sh norun
$ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SDL_RENDER_DRIVER=software \
./build-akgl/akbasic_example_galaga --frames 600 --autoplay
```
`--frames N` bounds the run; `--autoplay` is a scripted pilot that starts the
game, sweeps the floor and holds fire until the wave assembles; `--screenshot
PATH --screenshot-frame N` write a PNG from the render target — the figures in
this chapter are that flag's output, not pictures somebody took once. Synthetic
input goes through `akgl_controller_handle_event()` with constructed
`SDL_Event`s, never by calling the handlers directly — the point of autoplay is
to exercise the same path a keyboard does.
The last line of every run is the evidence:
```text
galaga: 600 frames, screen 1, score 1910, alive 9, kills bee 19 bfly 12 boss 0, shots bee 0 bfly 3 boss 0, script errors 0
```
Exiting 0 is not proof the wave flew. The readout is: kills and shots counted
per kind say the enemies entered, thought and fired, and **`script errors 0`**
says every one of the ~24,000 BASIC calls in those ten seconds came back clean.
A wave of dumb enemies still exits 0, and that count is how you notice. The
CTest entry `example_galaga` runs exactly this under the dummy SDL drivers,
which is what keeps both chapters honest.
---
That is the engine: a window, a starfield, a ship, bullets, screens, and an
interpreter that answers when called. Everything on screen so far is C. What
turns it into a GALAGA is [Chapter 21](21-tutorial-galaga-enemies.md) — the
three shared structures, the script that thinks through them, and a full wave
entering, breathing, diving and firing without another line of engine code.

View File

@@ -1,585 +0,0 @@
# 21. Tutorial: GALAGA — the structures and the AI
[Chapter 20](20-tutorial-galaga.md) built a C engine that boots the interpreter
and hands one actor to BASIC. This chapter builds everything that crosses the
boundary — the three shared structures — and then the script that thinks
through them: a full wave that enters, forms up, breathes, dives, fires and
dies, without another line of engine code.
![The wave assembling: bosses, butterflies and bees under BASIC control](images/galaga-wave.png)
The finished script is
[`examples/galaga/galaga.bas`](../examples/galaga/galaga.bas) — six `DEF`
functions and an `END`, nothing else. Editing it and re-running the game is the
whole development loop; the engine never rebuilds.
## What you will do
- **[Step 1](#step-1-declare-the-enemy-once-in-c)** — declare the enemy record
once, in C, and register it as a BASIC type
- **[Step 2](#step-2-bind-the-engines-own-actor)** — bind the engine's own
actor as the second type, which is the point of the whole exercise
- **[Step 3](#step-3-share-the-frame-and-the-dice)** — share the frame state,
and give the script randomness it cannot make itself
- **[Step 4](#step-4-why-bindings-and-not-arguments)** — see why the structures
are bindings rather than function arguments
- **[Step 5](#step-5-the-shape-of-the-script)** — learn the three language
rules that shape every enemy function
- **[Step 6](#step-6-the-shared-maneuvers)** — write the shared maneuvers:
glide home, dive, decide to fire
- **[Step 7](#step-7-the-three-kinds)** — write the bee, the butterfly and the
boss
- **[Step 8](#step-8-the-formation-c-or-basic)** — decide who owns the
formation, and lay it out
- **[Step 9](#step-9-when-a-script-dies)** — decide what a script error does to
the game, and make it do that
- **[Step 10](#step-10-prove-it)** — prove the boundary with a test that links
the real files
- **[Step 11](#step-11-the-cost-measured)** — measure what thinking in BASIC
costs, against the same logic in C
---
## Step 1: Declare the enemy once, in C
**Goal: one struct that both languages read and write, with one source of truth.**
An enemy is what the state machine needs to remember between frames, plus one
inbox and one outbox:
```c wrap=galagatypes requires=akgl
#define GALAGA_ENEMY_BEE 0
#define GALAGA_ENEMY_BUTTERFLY 1
#define GALAGA_ENEMY_BOSS 2
/*
* galaga_Enemy.state bits. The script owns these transitions; the engine only
* writes the word at spawn.
*
* 8 0
* 0 0 0 0 0 1 1 1
* | | `-- ENTERING: flying its entry path toward the formation slot
* | `---- FORMATION: holding (and breathing around) homex/homey
* `------ DIVING: attacking, off the grid until it leaves the screen
*/
#define GALAGA_ES_ENTERING (1 << 0)
#define GALAGA_ES_FORMATION (1 << 1)
#define GALAGA_ES_DIVING (1 << 2)
typedef struct galaga_Enemy
{
int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */
int32_t state; /* GALAGA_ES_* bit flags */
float homex; /* formation slot, in map pixels */
float homey;
float t; /* parametric clock for the current maneuver */
int32_t hp;
int32_t fire; /* outbox: script sets 1, engine consumes */
float rnd; /* inbox: engine writes fresh 0..1 each call */
} galaga_Enemy;
```
The C struct *is* the BASIC type. `akbasic_host_register_type()` takes a table
of field descriptors — the BASIC name with its suffix, the C representation,
and where the member sits — and after that the language's own machinery works
across the boundary with no second set of rules
([Chapter 16](16-structures.md)):
```c wrap=galagatypes requires=akgl
typedef struct galaga_Enemy
{
int32_t kind;
int32_t state;
float homex;
float homey;
float t;
int32_t hp;
int32_t fire;
float rnd;
} galaga_Enemy;
static const akbasic_HostField ENEMY_FIELDS[] = {
/* struct member BASIC name C representation */
AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
};
static const akbasic_HostType ENEMY_TYPE = {
"ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8
};
```
Three decisions are load-bearing here:
- **`AKBASIC_HOST_FIELD` takes the offset and the width from the member
itself**, via `offsetof` — so the two sides cannot drift. Writing them out by
hand is two chances to name the wrong member and no way to notice.
- **The script never declares a `TYPE`.** A host type and a script `TYPE` share
one namespace, and a script that tries to redeclare `ENEMY` is refused. The
"structure definitions" half of the boundary lives here, once.
- **The suffixes are the dialect's**: `#` is integer, `%` is float
([Chapter 3](03-the-language.md)). `HOMEX%` because a formation slot is a
pixel coordinate the glide arithmetic must not truncate.
The limits that shape the struct: a type may carry 16 fields and the runtime 16
types ([Chapter 16](16-structures.md)). `ENEMY` spends 8 fields; the game
spends 3 types.
## Step 2: Bind the engine's own actor
**Goal: the script writes the same bytes the renderer reads.**
The enemy record is the game's own invention. The second type is not — it is
libakgl's `akgl_Actor`, registered field-for-field over the engine's real
struct:
```c wrap=galagatypes requires=akgl
static const akbasic_HostField ACTOR_FIELDS[] = {
AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL )
};
static const akbasic_HostType ACTOR_TYPE = {
"ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4
};
```
This is the demonstrative point of the whole exercise. When the script writes
`ACTOR@.X%`, it writes `akgl_Actor.x` — the same memory the renderer reads on
the same frame. There is no copy going in, no copy coming out, and no code
between the script's decision and the engine's pixel. Null physics
(Chapter 20, Step 1) is what makes that safe: nothing else is trying to move
the actor.
Registration and the first binding happen at boot, before the script loads —
between `akbasic_runtime_init()` and `akbasic_runtime_load()` in Chapter 20's
boot sequence. A binding is **borrowed, never copied**, so the placeholders it
points at must be static storage:
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE));
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE));
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE));
CATCH(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY));
CATCH(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR));
CATCH(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared));
```
`akbasic_host_bind()` takes the script name, the registered type's name, and
the instance; after that, `SELF@` and `ACTOR@` are only ever *re*bound.
The per-frame call binds both names to *this* enemy before dispatching — one
binding per name, pointed at forty enemies in turn, which is what
`akbasic_host_rebind()` is for:
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
```
## Step 3: Share the frame, and the dice
**Goal: everything a diving enemy needs to know about the world, in one record.**
```c wrap=galagatypes requires=akgl
typedef struct galaga_Shared
{
float playerx; /* the player actor's position, this frame */
float playery;
int32_t wave;
float rnd; /* fresh 0..1 each frame; the issue #16 route */
} galaga_Shared;
```
`GAME@` is bound once at boot to this one global instance and never rebound;
the engine refreshes it at the top of every frame. The boss reads
`GAME@.PLAYERX%` to lead its dive; the fire decision reads it to know whether
anything is worth shooting at.
The `rnd` fields — one here per frame, one on each enemy per call — exist
because the engine's PRNG is the script's **only** source of randomness: write
`SELF@.RND% < DT% * 1.5` and an enemy's trigger finger is a dice roll. There
is no `RND` verb in this dialect; issue #16 tracks adding one, and Chapter
17's breakout hand-rolls a linear congruential generator in BASIC as the other
route. Here the engine fills the field, which also keeps a headless run the
same game on every machine — the PRNG is the example's own, not libc's.
## Step 4: Why bindings, and not arguments
**Goal: know why `SELF@` is a bound global rather than a parameter.**
The language can pass structures to functions — by value with `E@ AS ENEMY`,
by reference with `E@ AS PTR TO ENEMY` ([Chapter 16](16-structures.md)) — and
a host can construct those argument values, so the obvious alternative
interface is honest functions:
```basic norun
DEF UPDATEBEE(E@ AS PTR TO ENEMY, A@ AS PTR TO ACTOR, G@ AS PTR TO GAME, DT%)
```
It was measured before this chapter chose. Pointer arguments work — writes
through `E@->X%` land in the host struct, the type check refuses a wrong type,
by-value copies exactly as documented. What rules them out is the pool math:
| | bound globals | pointer arguments |
|---|---|---|
| value-pool slots per call | 0 | 1 per structure parameter, never returned |
| calls before exhaustion | unbounded | 1,015 measured (2,048-slot pool, 2 pointer args) |
| at 40 enemies per frame | unbounded | 25 frames |
| per-call cost | 148 us | 251 us |
A `@`-suffixed name always takes value-pool storage, and that pool never
reclaims — a documented property of structures, because a pointer may outlive
the scope that `DIM`med it. A *parameter* is a local that dies with the call,
but it pays the storage price of a `DIM` that must survive one; the pool
drains, and the wave stops thinking mid-flight. Issue #36 tracks it, with the
reduction for whoever fixes it. Until then: **bind and rebind for per-frame
host calls; pass structures only to functions called a bounded number of
times.**
## Step 5: The shape of the script
**Goal: the three rules every enemy function is written under.**
`galaga.bas` is definitions and an `END` — no top-level code, no line numbers,
no `LABEL`s. Three rules of the dialect shape every body in it.
**Rule 1: the left operand decides integer or float arithmetic**
([Chapter 3](03-the-language.md)). This will bite every enemy script exactly
once, so meet it now. The natural spelling of "move by speed times dt" moves
nothing:
```basic norun
ACTOR@.Y% = ACTOR@.Y% + 260 * DT%
```
`260` is an integer, it is on the left of `*`, so `DT%` — a float around
0.016 — is converted to integer **zero** before the multiply. Nothing fails;
the enemy simply does not move. The working spelling puts the float first:
```basic norun
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
SPD% = SELF@.T% * 150 + 260
```
Every expression in the finished script is written float-first. When an enemy
of yours will not move, this is the first thing to check.
**Rule 2: only the last `RETURN` may start a line.** A multi-line `DEF` body
runs until `RETURN` — and the *definition* is scanned the same way, ending at
the first line that begins with one. An early return therefore always rides an
`IF ... THEN RETURN 0` on one line, and exactly one line-leading `RETURN` ends
each function. The stagger guard at the top of every update function is the
idiom:
```basic norun
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
```
**Rule 3: the budgets are small and named.** Eight function slots exist
(`AKBASIC_MAX_FUNCTIONS`), each a measured 36 KiB of the runtime's 2.40 MiB.
This game defines six: three update functions, two shared maneuvers, one fire
decision. Nesting draws from the twelve-slot environment pool exactly as
`GOSUB` does; the deepest chain here is three (update → maneuver → nothing).
If a design needs a ninth function, raising the limit is one `#define` and
+36 KiB per slot — weighed, not assumed.
## Step 6: The shared maneuvers
**Goal: three helpers that make the three kinds one page each.**
Ease toward the formation slot, with a little entry swirl. Answers 1 once the
slot is reached — the caller flips the state on that answer:
```basic
DEF GLIDEHOME(DT%)
DX% = SELF@.HOMEX% - ACTOR@.X%
DY% = SELF@.HOMEY% - ACTOR@.Y%
K% = DT% * 4.5
IF K% > 1 THEN K% = 1
ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT%
ACTOR@.Y% = ACTOR@.Y% + DY% * K%
IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1
RETURN 0
END
```
One frame of a dive: accelerate downward, weave, lean toward the player's
column, and glide back in from the top after falling out the bottom. The
weave and the lean are parameters, which is what makes three kinds out of one
maneuver:
```basic
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
SPD% = SELF@.T% * 150 + 260
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT%
DX% = GAME@.PLAYERX% - ACTOR@.X%
IF DX% > 220 THEN DX% = 220
IF DX% < -220 THEN DX% = -220
ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT%
IF ACTOR@.Y% > 1040 THEN BEGIN
ACTOR@.Y% = 0.0 - 90
SELF@.STATE# = 1
SELF@.T% = 0
BEND
RETURN 0
END