Files
akbasic/tests/hoststruct.c

266 lines
9.4 KiB
C
Raw Permalink Normal View History

Share a host program's own C structures with a script A BASIC TYPE and a host C struct are the same thing seen from two sides, so both go in one type table and everything the language already does with a structure works across the boundary with no second set of rules. The host describes its struct once as a table of field descriptors and binds an instance: akbasic_host_bind(&SCRIPT, "FOE@", "ENEMY", &GOBLIN); after which `FOE@.HP# = FOE@.HP# - 10` decrements GOBLIN.hp in place, with no marshalling step the host has to remember to run. The sharing is done with shadow slots: a binding takes a run from the same value pool a DIMmed record uses, a field read refreshes its slot from host memory first, and a write converts back and stores. So the script always sees current values and its writes always land, while the rest of the interpreter goes on seeing one storage model instead of two. AKBASIC_HOST_FIELD takes the offset and the width from the same member, which is the only reason it is a macro: writing offsetof and sizeof out by hand is two chances to name the wrong member and no way to notice. A field name's suffix must agree with the C type it describes, refused at registration -- a host writing "HP%" over an int32_t has said two different things about one field and the script would believe the suffix. Conversion refuses rather than truncates. 200 into an int8_t, 70000 into an int16_t, -1 into a uint8_t and thirteen characters into a char[8] are each an error naming the field, because a silent wrap is found three frames later in code that did nothing wrong. Each width is tested separately, since a range check is exactly the thing that is right for int32_t and wrong for int8_t when only one of them is covered. The language's own distinction turns out to be the one a host needs, so there is one API rather than two: assignment copies and gives a script a private snapshot, POINT shares and lets it change the game, and which one happened is visible in the listing. Two things the work required. The prescan runs again on every RUN and used to wipe the whole type table, unregistering the host's types the first time a script ran; it now keeps what the host registered and drops only what the script declared. And akbasic_runtime_start() did not rewind, which cost nothing while a host started a script once and cost everything to a host running one per enemy -- the second start began past the end and silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:56:04 -04:00
/**
* @file hoststruct.c
* @brief Tests sharing a host C struct with a script.
*
* The assertions that matter are the ones about *conversion*, because that is
* the boundary where the two type systems disagree and where a silent answer
* would be worst. A range check that quietly wraps is found three frames later
* in code that did nothing wrong, so every refusal is asserted individually.
*
* The sharing itself is asserted in both directions: what the script sees when
* the host changes its struct underneath, and what the host sees when the script
* writes.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
typedef struct
{
char name[8];
int8_t tiny;
int16_t small;
int32_t hp;
uint8_t level;
float x;
double weight;
bool hostile;
} test_Enemy;
static const akbasic_HostField ENEMY_FIELDS[] = {
/* struct member BASIC name C representation */
AKBASIC_HOST_FIELD( test_Enemy, name, "NAME$", AKBASIC_HOSTFIELD_CSTRING ),
AKBASIC_HOST_FIELD( test_Enemy, tiny, "TINY#", AKBASIC_HOSTFIELD_INT8 ),
AKBASIC_HOST_FIELD( test_Enemy, small, "SMALL#", AKBASIC_HOSTFIELD_INT16 ),
AKBASIC_HOST_FIELD( test_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( test_Enemy, level, "LEVEL#", AKBASIC_HOSTFIELD_UINT8 ),
AKBASIC_HOST_FIELD( test_Enemy, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( test_Enemy, weight, "WEIGHT%", AKBASIC_HOSTFIELD_DOUBLE ),
AKBASIC_HOST_FIELD( test_Enemy, hostile, "HOSTILE#", AKBASIC_HOSTFIELD_BOOL )
};
static const akbasic_HostType ENEMY_TYPE = {
"ENEMY", sizeof(test_Enemy), ENEMY_FIELDS, 8
};
static test_Enemy ENEMY;
/** @brief A fresh runtime with ENEMY registered and bound to FOE@. */
static akerr_ErrorContext AKERR_NOIGNORE *bind_and_run(const char *program)
{
PREPARE_ERROR(errctx);
memset(&ENEMY, 0, sizeof(ENEMY));
snprintf(ENEMY.name, sizeof(ENEMY.name), "GOBLIN");
ENEMY.tiny = 1;
ENEMY.small = 2;
ENEMY.hp = 30;
ENEMY.level = 3;
ENEMY.x = 1.5f;
ENEMY.weight = 2.25;
ENEMY.hostile = true;
PASS(errctx, harness_start(NULL));
PASS(errctx, akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
PASS(errctx, akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, program));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
SUCCEED_RETURN(errctx);
}
/** @brief Every C representation reaches the script as the right BASIC value. */
static void test_reads(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.NAME$\n"
"20 PRINT FOE@.TINY#\n"
"30 PRINT FOE@.SMALL#\n"
"40 PRINT FOE@.HP#\n"
"50 PRINT FOE@.LEVEL#\n"
"60 PRINT FOE@.WEIGHT%\n"
"70 PRINT FOE@.HOSTILE#\n"));
/* A bool reads as -1, the Commodore convention every condition already uses. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "GOBLIN\n1\n2\n30\n3\n2.250000\n-1\n");
harness_stop();
}
/** @brief A write reaches the host's own struct, with no step in between. */
static void test_writes_reach_the_host(void)
{
TEST_REQUIRE_OK(bind_and_run("10 FOE@.HP# = FOE@.HP# - 10\n"
"20 FOE@.NAME$ = \"ORC\"\n"
"30 FOE@.HOSTILE# = 0\n"
"40 FOE@.WEIGHT% = 9.5\n"));
TEST_REQUIRE_INT(ENEMY.hp, 20);
TEST_REQUIRE_STR(ENEMY.name, "ORC");
TEST_REQUIRE(ENEMY.hostile == false, "a zero should have cleared the host's bool");
TEST_REQUIRE_FEQ(ENEMY.weight, 9.5);
harness_stop();
}
/**
* @brief The script sees what the host holds *now*, not a snapshot.
*
* The half of "shared" that a copy-in binding would get wrong: the game moves
* its own data between statements and the script has to see it.
*/
static void test_host_changes_are_visible(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
harness_stop();
/* Change it behind the script's back, then run again. */
TEST_REQUIRE_OK(harness_start(NULL));
memset(&ENEMY, 0, sizeof(ENEMY));
ENEMY.hp = 30;
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
TEST_REQUIRE_OK(akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT FOE@.HP#\n"));
ENEMY.hp = 77;
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "77\n");
harness_stop();
}
/**
* @brief A value that will not fit is refused, by field, rather than wrapped.
*
* One case per width, because a range check is exactly the sort of thing that is
* right for `int32_t` and wrong for `int8_t` when only one of them is tested.
*/
static void test_conversion_refuses_rather_than_truncates(void)
{
static const char *CASES[] = {
"10 FOE@.TINY# = 200\n", /* int8_t */
"10 FOE@.SMALL# = 70000\n", /* int16_t */
"10 FOE@.LEVEL# = -1\n", /* uint8_t */
"10 FOE@.NAME$ = \"MUCH TOO LONG\"\n" /* char[8] */
};
size_t i = 0;
for ( i = 0; i < sizeof(CASES) / sizeof(CASES[0]); i++ ) {
TEST_REQUIRE_OK(bind_and_run(CASES[i]));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "does not fit") != NULL,
"case %zu should be refused rather than truncated, got \"%s\"",
i, HARNESS_OUTPUT);
harness_stop();
}
/* And the host's struct is untouched by a refused write. */
TEST_REQUIRE_OK(bind_and_run("10 FOE@.TINY# = 200\n"));
TEST_REQUIRE_INT(ENEMY.tiny, 1);
harness_stop();
}
/**
* @brief Copy still copies, and POINT still shares -- across the boundary too.
*
* The reason there is one API rather than two: the language's own distinction
* turns out to be exactly the one a host needs, so which one a script used is
* visible in the listing.
*/
static void test_copy_versus_point(void)
{
TEST_REQUIRE_OK(bind_and_run("10 TYPE MINE\n"
"20 HP#\n"
"30 END TYPE\n"
"40 DIM SNAP@ AS MINE\n"
"50 SNAP@.HP# = FOE@.HP#\n"
"60 SNAP@.HP# = 0\n"
"70 PRINT FOE@.HP#\n"));
/* The snapshot moved; the game did not. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
TEST_REQUIRE_INT(ENEMY.hp, 30);
harness_stop();
TEST_REQUIRE_OK(bind_and_run("10 DIM LIVE@ AS PTR TO ENEMY\n"
"20 POINT LIVE@ AT FOE@\n"
"30 LIVE@->HP# = 5\n"));
TEST_REQUIRE_INT(ENEMY.hp, 5);
harness_stop();
}
/** @brief A suffix that disagrees with the C type is refused at registration. */
static void test_suffix_must_match_the_c_type(void)
{
static const akbasic_HostField WRONG[] = {
AKBASIC_HOST_FIELD( test_Enemy, hp, "HP%", AKBASIC_HOSTFIELD_INT32 )
};
static const akbasic_HostType WRONG_TYPE = { "WRONGT", sizeof(test_Enemy), WRONG, 1 };
akerr_ErrorContext *raised = NULL;
TEST_REQUIRE_OK(harness_start(NULL));
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &WRONG_TYPE);
TEST_REQUIRE(raised != NULL, "a float suffix over an int32_t must be refused");
TEST_REQUIRE_INT(raised->status, AKBASIC_ERR_VALUE);
test_discard_error(raised);
harness_stop();
}
/**
* @brief After unbinding, the name is refused rather than read.
*
* The whole reason the entry point exists: a binding is the only pointer this
* interpreter holds that it did not allocate, so a host has to be able to take
* it back before the storage goes.
*/
static void test_unbind_refuses_later_reads(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
memset(&ENEMY, 0, sizeof(ENEMY));
ENEMY.hp = 30;
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
TEST_REQUIRE_OK(akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
TEST_REQUIRE_OK(akbasic_host_unbind(&HARNESS_RUNTIME, "FOE@"));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "ERROR") != NULL,
"reading an unbound name should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief A binding survives the prescan, which runs again on every RUN. */
static void test_registration_survives_a_rerun(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
/*
* The prescan clears what the *script* declared and keeps what the *host*
* registered. Wiping the table outright unregistered the host's types the
* first time a script ran, with no way for the host to know it had to
* register them again.
*/
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n30\n");
harness_stop();
}
int main(void)
{
TEST_REQUIRE_OK(akbasic_error_register());
test_reads();
test_writes_reach_the_host();
test_host_changes_are_visible();
test_conversion_refuses_rather_than_truncates();
test_copy_versus_point();
test_suffix_must_match_the_c_type();
test_unbind_refuses_later_reads();
test_registration_survives_a_rerun();
return akbasic_test_failures;
}