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
This commit is contained in:
2026-08-04 09:01:59 -04:00
parent d5a0edd692
commit 54ab85a276
4 changed files with 322 additions and 5 deletions

View File

@@ -304,7 +304,7 @@ if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
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)
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)

View File

@@ -125,7 +125,41 @@ 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.
just uses it. 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:
@@ -198,6 +232,20 @@ 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
@@ -338,18 +386,84 @@ Four conventions worth keeping:
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. In CMake:
executable. The whole CMake recipe, inside an akbasic checkout with
`AKBASIC_WITH_AKGL=ON`:
```cmake
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
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,
@@ -360,7 +474,25 @@ 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:
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());

View File

@@ -35,6 +35,8 @@ whole development loop; the engine never rebuilds.
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
---
@@ -507,6 +509,56 @@ the readout tells you how each brain did:
galaga: 3000 frames, screen 2, score 2350, alive 0, kills bee 20 bfly 15 boss 1, shots bee 1 bfly 1 boss 1, script errors 0
```
## Step 11: The cost, measured
**Goal: the real price of the boundary, in numbers, next to the same logic in C.**
The interop test binary ends with a benchmark: 24,000 formation-hold updates —
forty enemies at sixty frames a second for ten seconds — once through
`galaga_script_update_enemy()` and once through a line-for-line C translation
of `UPDATEBEE` with its helpers inlined. Same guard, same branches, same
arithmetic; the difference is the interpreter. On this repository's build
machine (a two-core VM, the interpreter built `-O2`):
```text
benchmark: 24000 formation-hold updates, dt 0.016
BASIC through the boundary: 21.147 s 881.11 us/call 35.245 ms per 40-enemy frame
the same logic in C: 0.000 s 0.01 us/call 0.001 ms per 40-enemy frame
ratio: 61022x
```
The facts, without decoration:
- **A BASIC-driven update costs about four orders of magnitude more than the
same logic compiled.** The C translation of the whole state machine costs
tens of *nano*seconds; the scripted call costs high hundreds of
*micro*seconds.
- **The cost is per line executed, not per call.** The interpreter scans and
parses each body line from source text on every call; a 3-line body measured
~148 us on this class of machine, and this ~15-line body measures ~881 us.
Body length is the knob.
- **At this cost, forty thinking enemies spend ~35 ms per frame on this
hardware** — more than two 60 Hz frames. The shipped example visibly runs
below 60 fps on this machine while the whole wave is alive, and exactly at
its frame pace once the wave thins. A faster machine moves the numbers, not
the shape.
This is the measured version of decisions the chapters already made on
architectural grounds. Bullets, collision and the starfield are C
([Chapter 20](20-tutorial-galaga.md), Steps 2 and 4) — at two shots and forty
tests a frame, scripting them would multiply the call count for things that
decide nothing. The fire decision is one flag rather than a per-bullet
callback (Step 6): the script's call budget is bounded by the enemy count and
nothing else. C owns the formation and the spawn timing (Step 8), so zero
calls happen for enemies that do not exist yet. And the 36 KiB function slots
and 2.40 MiB runtime (Step 5) are the memory half of the same bill.
What the cost buys is the previous ten steps: behavior as data, edited and
swapped without a compiler. Whether ~900 us per thinking entity per frame is
acceptable is a per-project decision — fewer thinkers, shorter bodies, or a
lower think rate (every Nth frame) are the standard levers, and all three are
host-side choices this architecture leaves open.
---
Where to go from here: more waves are rows in the table; a new enemy kind is

View File

@@ -24,6 +24,7 @@
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <akerror.h>
@@ -115,12 +116,144 @@ static akerr_ErrorContext *run_claims(void)
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------ benchmark --- */
/**
* @brief UPDATEBEE's state machine, translated line for line into C.
*
* The native comparator for the benchmark below: the same guard, the same
* three branches, the same arithmetic as galaga.bas's UPDATEBEE with its
* helpers inlined. Nothing is simplified, so the timing difference is the
* interpreter's, not the algorithm's.
*/
static void native_updatebee(galaga_Enemy *enemy, akgl_Actor *actor, float dt)
{
float dx = 0.0f;
float dy = 0.0f;
float k = 0.0f;
int32_t s = 0;
enemy->t += dt;
if ( enemy->t < 0.0f ) {
return;
}
s = enemy->state;
if ( (s & GALAGA_ES_ENTERING) != 0 ) {
dx = enemy->homex - actor->x;
dy = enemy->homey - actor->y;
k = dt * 4.5f;
if ( k > 1.0f ) {
k = 1.0f;
}
actor->x += dx * k + sinf(enemy->t * 6.0f) * 90.0f * dt;
actor->y += dy * k;
if ( fabsf(dx) < 3.0f && fabsf(dy) < 3.0f ) {
enemy->state = GALAGA_ES_FORMATION;
enemy->t = 0.0f;
}
}
if ( (s & GALAGA_ES_FORMATION) != 0 ) {
actor->x = enemy->homex + sinf(enemy->t * 1.7f) * 16.0f;
actor->y = enemy->homey;
if ( enemy->rnd < dt * 0.04f ) {
enemy->state = GALAGA_ES_DIVING;
enemy->t = 0.0f;
}
}
if ( (s & GALAGA_ES_DIVING) != 0 ) {
actor->y += (enemy->t * 150.0f + 260.0f) * dt;
actor->x += sinf(enemy->t * 4.0f) * 130.0f * dt;
dx = galaga_shared.playerx - actor->x;
if ( dx > 220.0f ) {
dx = 220.0f;
}
if ( dx < -220.0f ) {
dx = -220.0f;
}
actor->x += dx * 0.2f * dt;
if ( actor->y > 1040.0f ) {
actor->y = -90.0f;
enemy->state = GALAGA_ES_ENTERING;
enemy->t = 0.0f;
}
dx = galaga_shared.playerx - actor->x;
if ( fabsf(dx) <= 140.0f && actor->y <= galaga_shared.playery
&& enemy->rnd < dt * 1.5f ) {
enemy->fire = 1;
}
}
}
static double seconds_since(const struct timespec *t0)
{
struct timespec t1;
clock_gettime(CLOCK_MONOTONIC, &t1);
return (double)(t1.tv_sec - t0->tv_sec) + (double)(t1.tv_nsec - t0->tv_nsec) / 1e9;
}
/**
* @brief The cost of thinking in BASIC, measured against the same logic in C.
*
* Both loops run the identical formation-hold workload, 24,000 calls -- forty
* enemies at sixty frames a second for ten seconds. Informational: nothing
* asserts on the timing, because CI machines vary; the numbers print so the
* tutorial can quote a real measurement.
*/
static akerr_ErrorContext *run_benchmark(void)
{
galaga_Enemy enemy;
akgl_Actor actor;
struct timespec t0;
double basic_s = 0.0;
double native_s = 0.0;
int i = 0;
PREPARE_ERROR(errctx);
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BEE;
enemy.state = GALAGA_ES_FORMATION;
enemy.homex = 400.0f;
enemy.homey = 300.0f;
enemy.rnd = 0.9f;
clock_gettime(CLOCK_MONOTONIC, &t0);
for ( i = 0; i < 24000; i++ ) {
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
}
basic_s = seconds_since(&t0);
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BEE;
enemy.state = GALAGA_ES_FORMATION;
enemy.homex = 400.0f;
enemy.homey = 300.0f;
enemy.rnd = 0.9f;
clock_gettime(CLOCK_MONOTONIC, &t0);
for ( i = 0; i < 24000; i++ ) {
native_updatebee(&enemy, &actor, 0.016f);
}
native_s = seconds_since(&t0);
printf("benchmark: 24000 formation-hold updates, dt 0.016\n");
printf(" BASIC through the boundary: %8.3f s %7.2f us/call %6.3f ms per 40-enemy frame\n",
basic_s, basic_s / 24000.0 * 1e6, basic_s / 24000.0 * 40.0 * 1e3);
printf(" the same logic in C: %8.3f s %7.2f us/call %6.3f ms per 40-enemy frame\n",
native_s, native_s / 24000.0 * 1e6, native_s / 24000.0 * 40.0 * 1e3);
printf(" ratio: %.0fx\n", basic_s / native_s);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, run_claims());
CATCH(errctx, run_benchmark());
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {