diff --git a/CMakeLists.txt b/CMakeLists.txt index f4ae118..c739f0c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/docs/20-tutorial-galaga.md b/docs/20-tutorial-galaga.md index a799c4e..39561e3 100644 --- a/docs/20-tutorial-galaga.md +++ b/docs/20-tutorial-galaga.md @@ -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 +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +``` 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 +#include +#include +#include +#include + +/* 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()); diff --git a/docs/21-tutorial-galaga-enemies.md b/docs/21-tutorial-galaga-enemies.md index 2ae4d76..7a7df0b 100644 --- a/docs/21-tutorial-galaga-enemies.md +++ b/docs/21-tutorial-galaga-enemies.md @@ -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 diff --git a/examples/galaga/interop_test.c b/examples/galaga/interop_test.c index 775d1c9..297947a 100644 --- a/examples/galaga/interop_test.c +++ b/examples/galaga/interop_test.c @@ -24,6 +24,7 @@ #include #include #include +#include #include @@ -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) {