Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.
Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).
Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00
|
|
|
/**
|
|
|
|
|
* @file interop_test.c
|
|
|
|
|
* @brief Round-trip test for the galaga boundary, hoststruct.c-style.
|
|
|
|
|
*
|
|
|
|
|
* Links the real script.c and the real galaga.bas -- not copies -- so this
|
|
|
|
|
* fails the moment the boundary and the script disagree. The four claims it
|
|
|
|
|
* pins:
|
|
|
|
|
*
|
|
|
|
|
* 1. The script writes the engine's actor memory: a formation enemy's sway
|
|
|
|
|
* lands in akgl_Actor.x with no marshalling step.
|
|
|
|
|
* 2. The outbox works: a diving enemy above the player raises FIRE# and the
|
|
|
|
|
* C side reads it.
|
|
|
|
|
* 3. The boss flips actor state bit 13 at one hit point -- the boundary
|
|
|
|
|
* crossed engine-ward.
|
|
|
|
|
* 4. Sustained calling holds: 24000 calls through the per-call
|
|
|
|
|
* akbasic_environment_zero() regime, the load a 40-enemy wave puts on
|
|
|
|
|
* the runtime in ten seconds.
|
|
|
|
|
*
|
|
|
|
|
* Exit status equals the number of failed claims.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include <math.h>
|
|
|
|
|
#include <stdbool.h>
|
|
|
|
|
#include <stddef.h>
|
|
|
|
|
#include <stdio.h>
|
|
|
|
|
#include <string.h>
|
2026-08-04 09:01:59 -04:00
|
|
|
#include <time.h>
|
Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.
Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).
Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00
|
|
|
|
|
|
|
|
#include <akerror.h>
|
|
|
|
|
|
|
|
|
|
#include <akgl/actor.h>
|
|
|
|
|
|
|
|
|
|
#include "galaga.h"
|
|
|
|
|
|
|
|
|
|
#ifndef GALAGA_SCRIPT_PATH
|
|
|
|
|
#define GALAGA_SCRIPT_PATH "galaga.bas"
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
/* script.c reads these; main.c usually defines them. This test is the host. */
|
|
|
|
|
galaga_Game galaga_game;
|
|
|
|
|
galaga_Shared galaga_shared;
|
|
|
|
|
|
|
|
|
|
static int FAILURES = 0;
|
|
|
|
|
|
|
|
|
|
#define CLAIM(__cond, __text) \
|
|
|
|
|
if ( !(__cond) ) { \
|
|
|
|
|
fprintf(stderr, "FAILED: %s\n", __text); \
|
|
|
|
|
FAILURES += 1; \
|
|
|
|
|
} else { \
|
|
|
|
|
printf("ok: %s\n", __text); \
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static akerr_ErrorContext *run_claims(void)
|
|
|
|
|
{
|
|
|
|
|
galaga_Enemy enemy;
|
|
|
|
|
akgl_Actor actor;
|
|
|
|
|
int i = 0;
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
PASS(errctx, galaga_script_boot((char *)GALAGA_SCRIPT_PATH));
|
|
|
|
|
|
|
|
|
|
/* --- 1: formation sway lands in the actor ---------------------------- */
|
|
|
|
|
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.hp = 1;
|
|
|
|
|
actor.x = 0.0f;
|
|
|
|
|
actor.y = 0.0f;
|
|
|
|
|
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
|
|
|
|
CLAIM((fabsf(actor.x - enemy.homex) <= 16.5f) && (actor.y == enemy.homey),
|
|
|
|
|
"a formation bee's sway is written into akgl_Actor.x/y by the script");
|
|
|
|
|
|
|
|
|
|
/* --- 2: the fire outbox ---------------------------------------------- */
|
|
|
|
|
memset(&enemy, 0, sizeof(enemy));
|
|
|
|
|
memset(&actor, 0, sizeof(actor));
|
|
|
|
|
enemy.kind = GALAGA_ENEMY_BEE;
|
|
|
|
|
enemy.state = GALAGA_ES_DIVING;
|
|
|
|
|
enemy.rnd = 0.0f; /* 0.0 < DT% * 1.5: always willing */
|
|
|
|
|
actor.x = 600.0f;
|
|
|
|
|
actor.y = 200.0f;
|
|
|
|
|
galaga_shared.playerx = 610.0f; /* just off the shot's column */
|
|
|
|
|
galaga_shared.playery = 860.0f; /* well below */
|
|
|
|
|
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
|
|
|
|
CLAIM(enemy.fire == 1,
|
|
|
|
|
"a diving bee above the player raises FIRE# for the engine to consume");
|
|
|
|
|
|
|
|
|
|
/* --- 3: the boss's hurt bit ------------------------------------------ */
|
|
|
|
|
memset(&enemy, 0, sizeof(enemy));
|
|
|
|
|
memset(&actor, 0, sizeof(actor));
|
|
|
|
|
enemy.kind = GALAGA_ENEMY_BOSS;
|
|
|
|
|
enemy.state = GALAGA_ES_FORMATION;
|
|
|
|
|
enemy.homex = 500.0f;
|
|
|
|
|
enemy.homey = 120.0f;
|
|
|
|
|
enemy.hp = 1;
|
|
|
|
|
actor.state = AKGL_ACTOR_STATE_ALIVE;
|
|
|
|
|
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
|
|
|
|
CLAIM((actor.state & AKGL_ACTOR_STATE_UNDEFINED_13) != 0,
|
|
|
|
|
"a boss at one hit point raises actor state bit 13 from BASIC");
|
|
|
|
|
|
|
|
|
|
/* --- 4: sustained calling --------------------------------------------- */
|
|
|
|
|
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;
|
|
|
|
|
for ( i = 0; i < 24000; i++ ) {
|
|
|
|
|
enemy.rnd = 0.9f; /* never dive: keep the state put */
|
|
|
|
|
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
|
|
|
|
}
|
|
|
|
|
CLAIM(galaga_game.script_errors == 0,
|
|
|
|
|
"24000 calls survive the per-call akbasic_environment_zero() regime");
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 09:01:59 -04:00
|
|
|
/* ------------------------------------------------------------ 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);
|
|
|
|
|
}
|
|
|
|
|
|
Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.
Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).
Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00
|
|
|
int main(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(errctx, run_claims());
|
2026-08-04 09:01:59 -04:00
|
|
|
CATCH(errctx, run_benchmark());
|
Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.
Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).
Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} HANDLE_DEFAULT(errctx) {
|
|
|
|
|
LOG_ERROR_WITH_MESSAGE(errctx, "the interop test could not run");
|
|
|
|
|
FAILURES += 1;
|
|
|
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
|
|
|
|
|
|
return FAILURES;
|
|
|
|
|
}
|