Files
akbasic/src/runtime_struct.c

432 lines
17 KiB
C
Raw Normal View History

Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
/**
* @file runtime_struct.c
* @brief The structure verbs and the field machinery behind `.` and `->`.
*
* **A structure is laid out exactly as an array is**, and that falls out of
* declaring the type: `TYPE` states the fields, so an instance has a known slot
* count and takes one contiguous run from the same value pool `DIM` already
* draws from. A field access is then offset arithmetic against an offset
* src/structtype.c computed before the program ran.
*
* Two things ride on a value to say which instance is meant -- `structtype` and
* `structbase`, both added to akbasic_Value for this and neither shared with the
* numeric fields. A `STRUCT` value and a `POINTER` value carry the *same*
* reference; what differs is what assignment does with it. A structure copies
* the slots it points at and a pointer copies the reference, which is the whole
* of the strict-pointer rule and the reason `.` and `->` are kept apart from the
* scanner all the way down to here.
*
* Copy is **deep through values and stops at pointers**, as it does for a C
* struct holding a pointer. Because a `TYPE` cannot contain itself by value, the
* depth of a copy is fixed by the type graph before the program starts, so no
* copy can recurse forever and none needs a runtime depth counter. Only
* rendering does, because a pointer can make the graph cyclic.
*/
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
#include <akstdlib.h>
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/structtype.h>
#include "verbs.h"
/* Most verbs answer "did something happen"; this is that answer. */
#define SUCCEED_TRUE(__obj, __dest) \
do { \
*(__dest) = &(__obj)->staticTrueValue; \
} while ( 0 )
akerr_ErrorContext *akbasic_struct_describe(akbasic_Runtime *obj, int typeindex, akbasic_Value *base,
bool pointer, akbasic_Value *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in struct describe");
PASS(errctx, akbasic_value_zero(dest));
dest->valuetype = (pointer ? AKBASIC_TYPE_POINTER : AKBASIC_TYPE_STRUCT);
dest->structtype = typeindex;
dest->structbase = base;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_struct_copy(akbasic_Runtime *obj, int typeindex,
akbasic_Value *src, akbasic_Value *dest)
{
PREPARE_ERROR(errctx);
akbasic_StructType *type = NULL;
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && src != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in struct copy");
FAIL_ZERO_RETURN(errctx, (typeindex >= 0 && typeindex < obj->structtypes.count),
AKBASIC_ERR_BOUNDS, "Structure type index %d is out of range", typeindex);
type = &obj->structtypes.types[typeindex];
if ( src == dest ) {
SUCCEED_RETURN(errctx);
}
/*
* Slot by slot rather than one memcpy of the run, because the two are not
* the same thing: a nested pointer field must copy its reference and a
* nested value field must copy its slots, and only walking the descriptor
* knows which is which. A memcpy would give the right answer today and the
* wrong one the moment a field needs anything but a byte copy -- a host
* binding, for instance.
*/
for ( i = 0; i < type->slotcount; i++ ) {
PASS(errctx, akbasic_value_clone(&src[i], &dest[i]));
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Resolve a field-access leaf to the slot it addresses.
*
* Walks the chain from the base outward, so `A@.POS@.X#` resolves `A@`, then
* `POS@` within it, then `X#` within that -- each step against a type the
* declaration already fixed, which is why a misspelled field can be refused by
* name at all.
*/
akerr_ErrorContext *akbasic_struct_resolve(akbasic_Runtime *obj, akbasic_ASTLeaf *expr,
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
akbasic_StructField **fielddest, akbasic_Value **slotdest,
void **hostdest)
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
{
PREPARE_ERROR(errctx);
akbasic_Value *basevalue = NULL;
akbasic_StructField *field = NULL;
akbasic_Value *slot = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && slotdest != NULL), AKERR_NULLPOINTER,
"NULL argument in struct resolve");
FAIL_ZERO_RETURN(errctx, (expr->leaftype == AKBASIC_LEAF_FIELD), AKBASIC_ERR_SYNTAX,
"Not a field access");
PASS(errctx, akbasic_runtime_evaluate(obj, expr->left, &basevalue));
/*
* The strict-pointer rule, and the only place it is enforced. `.` requires a
* structure and `->` requires a pointer to one; neither stands in for the
* other, so a reader always knows from the spelling whether the thing on the
* left is a copy or a reference to somebody else's data.
*/
if ( expr->operator_ == AKBASIC_TOK_ARROW ) {
FAIL_ZERO_RETURN(errctx, (basevalue->valuetype == AKBASIC_TYPE_POINTER),
AKBASIC_ERR_TYPE,
"-> needs a pointer on its left; use . to reach a field of a structure");
FAIL_ZERO_RETURN(errctx, (basevalue->structbase != NULL), AKBASIC_ERR_VALUE,
"This pointer is not pointing at anything yet; POINT it AT a structure first");
} else {
FAIL_ZERO_RETURN(errctx, (basevalue->valuetype == AKBASIC_TYPE_STRUCT),
AKBASIC_ERR_TYPE,
(basevalue->valuetype == AKBASIC_TYPE_POINTER
? "'.' needs a structure on its left; use -> to reach a field through a pointer"
: "'.' needs a structure on its left"));
FAIL_ZERO_RETURN(errctx, (basevalue->structbase != NULL), AKBASIC_ERR_VALUE,
"This structure has no storage; DIM it AS a type first");
}
PASS(errctx, akbasic_structtype_field(&obj->structtypes, basevalue->structtype,
expr->identifier, &field));
slot = basevalue->structbase + field->offset;
if ( fielddest != NULL ) {
*fielddest = field;
}
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
if ( hostdest != NULL ) {
*hostdest = basevalue->hostbase;
}
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
*slotdest = slot;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_struct_evaluate_field(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_StructField *field = NULL;
akbasic_Value *slot = NULL;
akbasic_Value *out = NULL;
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
void *hostbase = NULL;
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
PASS(errctx, akbasic_struct_resolve(obj, expr, &field, &slot, &hostbase));
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
/*
* A nested structure evaluates to a description of where it lives, not to a
* copy of it: `A@.POS@.X#` has to reach through it, and `B@ = A@.POS@` has
* to know how many slots to copy. The copy happens in assignment, which is
* the one place that knows a copy was asked for.
*/
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));
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
if ( hostbase != NULL ) {
out->hostbase = (char *)hostbase + field->hostoffset;
}
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
*dest = out;
SUCCEED_RETURN(errctx);
}
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
/*
* 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));
}
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
*dest = slot;
SUCCEED_RETURN(errctx);
}
/**
* @brief Render a structure the way PRINT does.
*
* `RECT(W#=3, H#=4)`. Bounded by depth because a pointer can make the graph
* cyclic -- copy cannot follow one, but rendering must, or a linked list would
* print as an unhelpful `NODE(VAL#=1, NEXT@=...)` and stop being worth printing.
*/
akerr_ErrorContext *akbasic_struct_to_string(akbasic_Runtime *obj, int typeindex, akbasic_Value *base,
int depth, char *dest, size_t len)
{
PREPARE_ERROR(errctx);
char rendered[AKBASIC_MAX_STRING_LENGTH];
akbasic_StructType *type = NULL;
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
const char *separator = NULL;
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
size_t used = 0;
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
size_t seplen = 0;
size_t namelen = 0;
size_t renderedlen = 0;
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
int written = 0;
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in struct to_string");
FAIL_ZERO_RETURN(errctx, (typeindex >= 0 && typeindex < obj->structtypes.count),
AKBASIC_ERR_BOUNDS, "Structure type index %d is out of range", typeindex);
type = &obj->structtypes.types[typeindex];
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
/*
* These two stay on raw snprintf on purpose, and are two of the six such
* sites left in src/. Both want truncation as an *answer*: `dest` and `len`
* come from the caller, this renders a value for display, and a name too long
* for the buffer should print short rather than fail the whole PRINT. The
* second one reads the return value to detect it -- on overflow `used` lands
* past `len`, which is exactly what makes the field loop below and the
* closing ")" both skip.
*
* aksl_snprintf treats truncation as AKERR_OUTOFBOUNDS and its `count` is 0
* on that path, so it can express neither the tolerance nor the detection.
* libakstdlib #34 is the issue that would give `count` the required length
* back; until it lands there is nothing to convert these to.
*/
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
if ( depth >= AKBASIC_MAX_STRUCT_DEPTH ) {
snprintf(dest, len, "%s(...)", type->name);
SUCCEED_RETURN(errctx);
}
written = snprintf(dest, len, "%s(", type->name);
used = (written > 0 ? (size_t)written : 0);
for ( i = 0; i < type->fieldcount && used + 1 < len; i++ ) {
akbasic_StructField *field = &type->fields[i];
akbasic_Value *slot = base + field->offset;
switch ( field->kind ) {
case AKBASIC_FIELD_STRUCT:
PASS(errctx, akbasic_struct_to_string(obj, field->typeindex, slot, depth + 1,
rendered, sizeof(rendered)));
break;
case AKBASIC_FIELD_POINTER:
if ( slot->structbase == NULL ) {
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
PASS(errctx, aksl_snprintf(&written, rendered, sizeof(rendered), "NOTHING"));
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
} else {
PASS(errctx, akbasic_struct_to_string(obj, slot->structtype, slot->structbase,
depth + 1, rendered, sizeof(rendered)));
}
break;
default:
PASS(errctx, akbasic_value_to_string(slot, rendered, sizeof(rendered)));
break;
}
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
separator = (i == 0 ? "" : ", ");
PASS(errctx, aksl_strlen(separator, &seplen));
PASS(errctx, aksl_strlen(field->name, &namelen));
PASS(errctx, aksl_strlen(rendered, &renderedlen));
/*
* A render that does not fit ends the list rather than failing it, so the
* fit is decided here -- aksl_snprintf treats truncation as an error, and
* the one byte counted beyond the three lengths is the `=`.
*/
if ( used + seplen + namelen + 1 + renderedlen >= len ) {
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
break;
}
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
PASS(errctx, aksl_snprintf(&written, dest + used, len - used, "%s%s=%s",
separator, field->name, rendered));
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
used += (size_t)written;
}
if ( used + 1 < len ) {
Port onto libakstdlib 2b79aca and convert the eight bool predicates akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2% bypassed, against 86.4% on the same tree before this. The submodule bump 669b2b3 -> 2b79aca needed no source change of its own: the release is drop-in for what akbasic already used. Seven of the eight sites the earlier port left on raw libc change their own signature rather than swallowing an error, per andrew's ruling on libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end, peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's scroll/newline/putchar_at/echo_line/edit_key chain all return an akerr_ErrorContext * and hand the answer back through an out parameter. is_waiting_for and is_waiting_for_any are a public header change; every call site that used one as a term in a condition hoists it into a statement first. verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the comparator's signature, so there is no out parameter to report through -- which is what libakstdlib#38 concluded. It carries a comment saying so and saying why the bypass is safe there. Six snprintf sites stay raw because they want truncation as an answer rather than an error, and aksl_snprintf cannot express that until libakstdlib#34 hands the required length back. Each of the six says so at the site. Two of them, in host.c, are a latent defect rather than a decision: a host type name over 31 characters truncates silently and two sharing a prefix then collide, where structtype.c refuses the same case. DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the PASS in it returned past CLEANUP, so a scan error left the file open. Hoisting the loop into its own helper to convert fgets fixes it. Refs libakstdlib#26, libakstdlib#38 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00
PASS(errctx, aksl_snprintf(&written, dest + used, len - used, ")"));
Add records: TYPE, DIM ... AS, field access, copy on assign BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:39:47 -04:00
}
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------------- TYPE --- */
akerr_ErrorContext *akbasic_cmd_type(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
int block = -1;
(void)expr; (void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in TYPE");
/*
* The declaration was read before the program started; what is left to do at
* run time is to *not* execute the block. Its field lines are declarations,
* and letting them run would evaluate each field name as a bare identifier
* and quietly create a global of that name -- so this jumps past END TYPE.
*/
PASS(errctx, akbasic_structtype_block_at(&obj->structtypes, obj->environment->lineno, &block));
FAIL_ZERO_RETURN(errctx, (block >= 0), AKBASIC_ERR_STATE,
"TYPE was not read by the prescan; this line is not the start of a block");
obj->environment->nextline = obj->structtypes.types[block].lastline + 1;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:48:09 -04:00
/* ---------------------------------------------------------------- POINT --- */
/**
* @brief Find the slot a pointer *lives in*, which is not the same as its value.
*
* `POINT` writes a reference into a pointer, so it needs the storage rather than
* a copy of what is in it -- the same distinction `POKE` and `POINTER()` already
* make with `eval_clone_identifiers`. Both spellings of a pointer are accepted:
* a variable declared `AS PTR TO`, and a field declared the same way.
*/
static akerr_ErrorContext *pointer_slot(akbasic_Runtime *obj, akbasic_ASTLeaf *leaf,
akbasic_Value **slotdest, int *typedest)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
akbasic_StructField *field = NULL;
akbasic_Value *slot = NULL;
FAIL_ZERO_RETURN(errctx, (leaf != NULL), AKERR_NULLPOINTER, "NULL leaf in pointer_slot");
if ( leaf->leaftype == AKBASIC_LEAF_FIELD ) {
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
PASS(errctx, akbasic_struct_resolve(obj, leaf, &field, &slot, NULL));
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:48:09 -04:00
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);
*slotdest = slot;
*typedest = field->typeindex;
SUCCEED_RETURN(errctx);
}
FAIL_ZERO_RETURN(errctx, (leaf->leaftype == AKBASIC_LEAF_IDENTIFIER_STRUCT),
AKBASIC_ERR_TYPE, "POINT expects a pointer on its left");
PASS(errctx, akbasic_environment_get(obj->environment, leaf->identifier, &variable));
FAIL_ZERO_RETURN(errctx, (variable != NULL), AKBASIC_ERR_UNDEFINED,
"Identifier %s is undefined", leaf->identifier);
FAIL_ZERO_RETURN(errctx, (variable->ispointer), AKBASIC_ERR_TYPE,
"%s was not DIMmed AS PTR TO a type, so it cannot point at anything",
leaf->identifier);
*slotdest = &variable->values[0];
*typedest = variable->structtype;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_point(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *pointerleaf = NULL;
akbasic_ASTLeaf *targetleaf = NULL;
akbasic_Value *slot = NULL;
akbasic_Value *target = NULL;
int pointertype = -1;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in POINT");
pointerleaf = akbasic_leaf_first_argument(expr);
FAIL_ZERO_RETURN(errctx, (pointerleaf != NULL && pointerleaf->next != NULL),
AKBASIC_ERR_SYNTAX, "Expected POINT <pointer> AT <structure>");
targetleaf = pointerleaf->next;
PASS(errctx, pointer_slot(obj, pointerleaf, &slot, &pointertype));
PASS(errctx, akbasic_runtime_evaluate(obj, targetleaf, &target));
/*
* The target must be a structure, not another pointer. Pointing one pointer
* at another is spelled `Q@ = P@` -- assignment copies a reference, which is
* the one place in the language where copying does not deep-copy, and having
* two ways to write it would blur exactly the line this feature draws.
*/
FAIL_ZERO_RETURN(errctx, (target->valuetype == AKBASIC_TYPE_STRUCT), AKBASIC_ERR_TYPE,
(target->valuetype == AKBASIC_TYPE_POINTER
? "POINT ... AT takes a structure; to copy one pointer to another, assign it"
: "POINT ... AT takes a structure"));
FAIL_ZERO_RETURN(errctx, (target->structbase != NULL), AKBASIC_ERR_VALUE,
"That structure has no storage; DIM it AS a type first");
FAIL_ZERO_RETURN(errctx, (target->structtype == pointertype), AKBASIC_ERR_TYPE,
"This pointer is to %s and cannot be pointed at a %s",
obj->structtypes.types[pointertype].name,
obj->structtypes.types[target->structtype].name);
PASS(errctx, akbasic_value_zero(slot));
slot->valuetype = AKBASIC_TYPE_POINTER;
slot->structtype = target->structtype;
slot->structbase = target->structbase;
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> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:56:04 -04:00
/*
* 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;
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 11:48:09 -04:00
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_struct_init_slots(akbasic_Runtime *obj, int typeindex, akbasic_Value *base)
{
PREPARE_ERROR(errctx);
akbasic_StructType *type = NULL;
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && base != NULL), AKERR_NULLPOINTER,
"NULL argument in struct init_slots");
FAIL_ZERO_RETURN(errctx, (typeindex >= 0 && typeindex < obj->structtypes.count),
AKBASIC_ERR_BOUNDS, "Structure type index %d is out of range", typeindex);
type = &obj->structtypes.types[typeindex];
for ( i = 0; i < type->fieldcount; i++ ) {
akbasic_StructField *field = &type->fields[i];
akbasic_Value *slot = base + field->offset;
switch ( field->kind ) {
case AKBASIC_FIELD_STRUCT:
/* Recurses over the type graph, which is finite: a TYPE cannot
contain itself by value, so this cannot run away. */
PASS(errctx, akbasic_struct_init_slots(obj, field->typeindex, slot));
break;
case AKBASIC_FIELD_POINTER:
PASS(errctx, akbasic_value_zero(slot));
slot->valuetype = AKBASIC_TYPE_POINTER;
slot->structtype = field->typeindex;
slot->structbase = NULL;
break;
default:
PASS(errctx, akbasic_value_zero(slot));
slot->valuetype = field->valuetype;
break;
}
}
SUCCEED_RETURN(errctx);
}