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>
This commit is contained in:
2026-08-01 11:56:04 -04:00
parent 2a3f68d2c4
commit 631c70ce7d
13 changed files with 1068 additions and 7 deletions

View File

@@ -94,7 +94,8 @@ akerr_ErrorContext *akbasic_struct_copy(akbasic_Runtime *obj, int typeindex,
* name at all.
*/
akerr_ErrorContext *akbasic_struct_resolve(akbasic_Runtime *obj, akbasic_ASTLeaf *expr,
akbasic_StructField **fielddest, akbasic_Value **slotdest)
akbasic_StructField **fielddest, akbasic_Value **slotdest,
void **hostdest)
{
PREPARE_ERROR(errctx);
akbasic_Value *basevalue = NULL;
@@ -136,6 +137,9 @@ akerr_ErrorContext *akbasic_struct_resolve(akbasic_Runtime *obj, akbasic_ASTLeaf
if ( fielddest != NULL ) {
*fielddest = field;
}
if ( hostdest != NULL ) {
*hostdest = basevalue->hostbase;
}
*slotdest = slot;
SUCCEED_RETURN(errctx);
}
@@ -146,8 +150,9 @@ akerr_ErrorContext *akbasic_struct_evaluate_field(akbasic_Runtime *obj, akbasic_
akbasic_StructField *field = NULL;
akbasic_Value *slot = NULL;
akbasic_Value *out = NULL;
void *hostbase = NULL;
PASS(errctx, akbasic_struct_resolve(obj, expr, &field, &slot));
PASS(errctx, akbasic_struct_resolve(obj, expr, &field, &slot, &hostbase));
/*
* A nested structure evaluates to a description of where it lives, not to a
@@ -158,9 +163,20 @@ akerr_ErrorContext *akbasic_struct_evaluate_field(akbasic_Runtime *obj, akbasic_
if ( field->kind == AKBASIC_FIELD_STRUCT ) {
PASS(errctx, akbasic_environment_new_value(obj->environment, &out));
PASS(errctx, akbasic_struct_describe(obj, field->typeindex, slot, false, out));
if ( hostbase != NULL ) {
out->hostbase = (char *)hostbase + field->hostoffset;
}
*dest = out;
SUCCEED_RETURN(errctx);
}
/*
* A host-backed field is read from the host's memory every time, not from
* the shadow slot -- the game may have moved it since the script last
* looked, and "shared" has to mean shared in both directions.
*/
if ( hostbase != NULL ) {
PASS(errctx, akbasic_host_read_field(obj, field, hostbase, slot));
}
*dest = slot;
SUCCEED_RETURN(errctx);
}
@@ -275,7 +291,7 @@ static akerr_ErrorContext *pointer_slot(akbasic_Runtime *obj, akbasic_ASTLeaf *l
FAIL_ZERO_RETURN(errctx, (leaf != NULL), AKERR_NULLPOINTER, "NULL leaf in pointer_slot");
if ( leaf->leaftype == AKBASIC_LEAF_FIELD ) {
PASS(errctx, akbasic_struct_resolve(obj, leaf, &field, &slot));
PASS(errctx, akbasic_struct_resolve(obj, leaf, &field, &slot, NULL));
FAIL_ZERO_RETURN(errctx, (field->kind == AKBASIC_FIELD_POINTER), AKBASIC_ERR_TYPE,
"%s is not a pointer; only a field declared PTR TO can be POINTed",
field->name);
@@ -338,6 +354,13 @@ akerr_ErrorContext *akbasic_cmd_point(akbasic_Runtime *obj, akbasic_ASTLeaf *exp
slot->valuetype = AKBASIC_TYPE_POINTER;
slot->structtype = target->structtype;
slot->structbase = target->structbase;
/*
* And the host instance, when there is one. Without it a pointer aimed at a
* host binding would write the shadow slot and stop there -- the script
* would see its own change and the game would not, which is the one outcome
* worse than refusing outright.
*/
slot->hostbase = target->hostbase;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}