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
266 lines
8.1 KiB
C
266 lines
8.1 KiB
C
/**
|
|
* @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>
|
|
#include <time.h>
|
|
|
|
#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);
|
|
}
|
|
|
|
/* ------------------------------------------------------------ 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) {
|
|
LOG_ERROR_WITH_MESSAGE(errctx, "the interop test could not run");
|
|
FAILURES += 1;
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
return FAILURES;
|
|
}
|