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

175
include/akbasic/host.h Normal file
View File

@@ -0,0 +1,175 @@
/**
* @file host.h
* @brief Sharing 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 live in one type table and everything the language already does --
* copy on assign, `PTR TO`, `.` and `->` -- 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 to a name. A script then reads and writes the game's real memory:
*
* ```
* FOE@.HP# = FOE@.HP# - 10
* ```
*
* decrements the host's `hp` in place, with no marshalling step the host has to
* remember to run.
*
* **How the sharing works.** A binding takes a shadow run of value slots from
* the same pool a `DIM`med record uses, and every field read refreshes its slot
* from host memory while every write converts back and stores. So the script
* always sees current values and its writes always land, without the
* interpreter having to hold two storage models for one language.
*
* **Conversion refuses rather than truncates.** Writing 70000 into an `int16_t`
* field or 40 characters into a `char[32]` is an error naming the field, not a
* silent wrap. Two conversions are lossy and say so in the documentation: a host
* `float` round-trips through BASIC's doubles, and a `CSTRING` has a width BASIC
* strings do not.
*
* **This is the one place akbasic holds a pointer it did not allocate.**
* Everything else is pool-bounded and diagnosable. See akbasic_host_bind().
*/
#ifndef _AKBASIC_HOST_H_
#define _AKBASIC_HOST_H_
#include <stddef.h>
#include <akerror.h>
#include <akbasic/types.h>
struct akbasic_Runtime;
/** @brief How a host field is represented in C, which is what says how to convert it. */
typedef enum
{
AKBASIC_HOSTFIELD_INT8 = 0,
AKBASIC_HOSTFIELD_INT16,
AKBASIC_HOSTFIELD_INT32,
AKBASIC_HOSTFIELD_INT64,
AKBASIC_HOSTFIELD_UINT8,
AKBASIC_HOSTFIELD_UINT16,
AKBASIC_HOSTFIELD_UINT32,
AKBASIC_HOSTFIELD_BOOL,
AKBASIC_HOSTFIELD_FLOAT, /* C float; BASIC floats are double */
AKBASIC_HOSTFIELD_DOUBLE,
AKBASIC_HOSTFIELD_CSTRING, /* fixed char[]; `width` is its size */
AKBASIC_HOSTFIELD_STRUCT /* nested host struct; `typename_` names it */
} akbasic_HostFieldKind;
/** @brief One field of a host structure, in BASIC terms and in C terms. */
typedef struct
{
const char *name; /** BASIC field name, suffix included */
akbasic_HostFieldKind kind;
size_t offset; /** offsetof() within the host struct */
size_t width; /** sizeof() the member; CSTRING needs it */
const char *typename_; /** for STRUCT, the registered type name */
} akbasic_HostField;
/** @brief A host structure type: a name, a size, and its fields. */
typedef struct
{
const char *name; /** BASIC type name, no suffix */
size_t size; /** sizeof() the host struct */
const akbasic_HostField *fields;
int fieldcount;
} akbasic_HostType;
/**
* @brief Build a field descriptor from the member itself.
*
* Takes the offset and the width from the same member, so the two cannot drift
* apart -- writing them out by hand is two chances to name the wrong member and
* no way to notice. That is the only reason this is a macro.
*/
#define AKBASIC_HOST_FIELD(__struct, __member, __name, __kind) \
{ (__name), (__kind), offsetof(__struct, __member), \
sizeof(((__struct *)0)->__member), NULL }
/** @brief The same, for a field that is itself a registered host structure. */
#define AKBASIC_HOST_FIELD_STRUCT(__struct, __member, __name, __type) \
{ (__name), AKBASIC_HOSTFIELD_STRUCT, offsetof(__struct, __member), \
sizeof(((__struct *)0)->__member), (__type) }
/**
* @brief Register a host structure type under its BASIC name.
*
* The descriptor is **borrowed, not copied**: it must outlive the runtime, which
* is why every example makes it `static const`.
*
* Registering before the script is loaded is the normal case. A host type and a
* `TYPE` the script declares share one namespace, so a script cannot declare a
* type the host already registered -- and would be refused if it tried.
*
* @param obj Object to initialize, inspect, or modify.
* @param type The host's description of its own struct.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When either argument is NULL.
* @throws AKBASIC_ERR_VALUE When a field name carries no type suffix, a nested
* type is not registered, or the name is already taken.
* @throws AKBASIC_ERR_BOUNDS When the type table or a field list is full.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_register_type(struct akbasic_Runtime *obj, const akbasic_HostType *type);
/**
* @brief Bind one instance of a registered host type to a script variable.
*
* Creates the variable in the script's *outermost* scope, for the reason
* akbasic_runtime_global() exists: a binding made during a suspended run would
* otherwise land in whatever `FOR` or `GOSUB` body happens to be active and die
* with it.
*
* **`instance` is borrowed and never copied.** It must outlive the binding. A
* struct on a stack frame that returns, or one the host frees, leaves the script
* reading freed memory -- and this is the only way to get a wild pointer into an
* interpreter that otherwise cannot produce one. Bind globals or arena objects,
* and call akbasic_host_unbind() before the storage goes.
*
* @param obj Object to initialize, inspect, or modify.
* @param name Script variable name, `@`-suffixed.
* @param typename_ A type registered by akbasic_host_register_type().
* @param instance The host's struct. Borrowed; must outlive the binding.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When any argument is NULL.
* @throws AKBASIC_ERR_UNDEFINED When `typename_` is not registered.
* @throws AKBASIC_ERR_VALUE When `name` does not end in `@`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_bind(struct akbasic_Runtime *obj, const char *name, const char *typename_, void *instance);
/**
* @brief Point an existing binding at a different instance of the same type.
*
* The per-frame call. A host iterating its enemies rebinds one name rather than
* creating eight, which keeps the binding flat and lets one script be written
* against `FOE@` and run once per enemy.
*
* @param obj Object to initialize, inspect, or modify.
* @param name A name already bound by akbasic_host_bind().
* @param instance The host's struct. Borrowed; must outlive the binding.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When any argument is NULL.
* @throws AKBASIC_ERR_UNDEFINED When `name` is not bound.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_rebind(struct akbasic_Runtime *obj, const char *name, void *instance);
/**
* @brief Break a binding, leaving the name pointing at nothing.
*
* Call it before the instance's storage goes away. After this a script reading
* that name is refused by name rather than reading freed memory, which is the
* difference between a diagnosable script error and undefined behaviour.
*
* @param obj Object to initialize, inspect, or modify.
* @param name A name already bound by akbasic_host_bind().
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When either argument is NULL.
* @throws AKBASIC_ERR_UNDEFINED When `name` is not bound.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_unbind(struct akbasic_Runtime *obj, const char *name);
#endif // _AKBASIC_HOST_H_

View File

@@ -26,6 +26,7 @@
#include <akerror.h>
#include <akbasic/grammar.h>
#include <akbasic/host.h>
#include <akbasic/types.h>
struct akbasic_Runtime;
@@ -47,6 +48,14 @@ typedef struct
int typeindex; /** for STRUCT and POINTER */
int offset; /** slots from the start of the instance */
int slotcount; /** slots this field occupies */
/*
* Where this field's bytes live in the *host's* struct, when the type came
* from akbasic_host_register_type(). Unused for a type the script declared,
* whose fields are value slots and have no other representation.
*/
akbasic_HostFieldKind hostkind;
size_t hostoffset;
size_t hostwidth;
} akbasic_StructField;
/**
@@ -66,6 +75,14 @@ typedef struct
int64_t firstline;
int64_t lastline;
bool used;
/*
* True when the host described this type rather than the script declaring
* it. The difference is only in where a field's bytes are; everything the
* language does with a structure is the same either way, which is the point
* of putting both in one table.
*/
bool ishost;
size_t hostsize;
} akbasic_StructType;
/** @brief Every `TYPE` a program declared. Lives on the runtime. */
@@ -169,7 +186,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_copy(struct akbasic_Runtime *o
* Enforces the strict-pointer rule on the way: `.` requires a structure on its
* left and `->` requires a pointer, and neither stands in for the other.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_resolve(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_StructField **fielddest, akbasic_Value **slotdest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_resolve(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_StructField **fielddest, akbasic_Value **slotdest, void **hostdest);
/** @brief Evaluate a field access to the value it yields. */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_evaluate_field(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value **dest);
@@ -186,4 +203,15 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_init_slots(struct akbasic_Runt
/** @brief Render an instance the way PRINT does, bounded by depth. */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_struct_to_string(struct akbasic_Runtime *obj, int typeindex, akbasic_Value *base, int depth, char *dest, size_t len);
/* ------------------------------------ host-backed instances (src/host.c) --- */
/** @brief Read one host field into a value, converting from its C type. */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_read_field(struct akbasic_Runtime *obj, akbasic_StructField *field, void *hostbase, akbasic_Value *dest);
/** @brief Write a value back into one host field, refusing what will not fit. */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_write_field(struct akbasic_Runtime *obj, akbasic_StructField *field, void *hostbase, akbasic_Value *src);
/** @brief Refresh a binding's whole shadow run from the host's struct. */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_host_refresh(struct akbasic_Runtime *obj, int typeindex, void *hostbase, akbasic_Value *slots);
#endif // _AKBASIC_STRUCTTYPE_H_

View File

@@ -112,6 +112,13 @@ typedef struct akbasic_Value
*/
int structtype; /* index into the type table, -1 for none */
struct akbasic_Value *structbase; /* first slot of the instance */
/*
* The host's own struct, when this describes a host binding. The slots above
* are then a *shadow*: a read refreshes one from here and a write converts
* back into here, so a script sees current values and its writes land, with
* one storage model instead of two.
*/
void *hostbase;
} akbasic_Value;
#endif // _AKBASIC_TYPES_H_

View File

@@ -34,6 +34,7 @@ typedef struct
*/
int structtype; /** type index, -1 when not a structure */
bool ispointer; /** declared PTR TO rather than a value */
void *hostbase; /** the host's struct, for a binding */
} akbasic_Variable;
/**