Files
akbasic/src/sprite_akgl.c

1016 lines
39 KiB
C
Raw Normal View History

Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
/**
* @file sprite_akgl.c
* @brief Wires the sprite backend record to libakgl's actors.
*
* Each of BASIC's eight sprites becomes a full libakgl object graph -- a
* spritesheet holding one texture, a sprite cutting one frame from it, a
* character mapping state 0 to that sprite, and an actor instantiating the
* character -- so a host game sees BASIC's sprites in libakgl's actor registry
* alongside its own and can do anything to them it can do to an actor.
*
* **None of that comes from JSON.** akgl_sprite_load_json() is a thin wrapper
* over the same four initializers plus writes to public struct fields, and every
* field it fills from a document -- frame list, animation speed, loop flags,
* state-to-sprite map -- is something a Commodore sprite does not have. The one
* exception is the image, and that is exactly where this file *does* call
* libakgl: `SPRSAV "ship.png", 1` resolves through akgl_path_relative() and
* loads through akgl_spritesheet_initialize(), the same two calls
* akgl_sprite_load_json_spritesheet() makes.
*
Take libakgl 0.5.0 and rename every symbol it namespaced 0.5.0 gives every exported symbol the akgl_ prefix, which is the first libakgl release to break this project's source rather than only its ABI. The soname goes to libakgl.so.0.5 and include/akbasic/akgl.h asserts the floor. What moved here: akgl_render_bind2d is akgl_render_2d_bind, akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the renderer, camera and window globals carry the prefix, and _akgl_renderer and _akgl_camera are akgl_default_renderer and akgl_default_camera. The renames were applied by site rather than by pattern, because renderer is also a parameter name in src/sprite_akgl.c and a struct member throughout src/frontend_akgl.c -- a substitution would have rewritten both without a word. That is the same trap upstream describes hitting, and it is worth knowing that the defect behind the rename was not cosmetic: an exported global called renderer collided with a test's own variable, the executable's definition preempted the library's, and every texture load in that suite failed while the suite passed. 0.5.0 also fixes libakgl defect 26, which was one of the two reasons src/sprite_akgl.c installs its own renderfunc: akgl_actor_render took its destination height from the sprite's width, drawing a 24x21 Commodore sprite as a 24x24 square. The renderfunc stays, because the other reason has not moved -- an actor carries one scalar scale and SPRITE has separate x- and y-expand bits -- but the comments and TODO.md deviation 40 no longer claim a defect that is fixed. Every documentation figure re-renders byte-identical under the new library, which is what says the sprite and drawing paths did not move underneath them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:01:43 -04:00
* Every actor gets a renderfunc of its own rather than akgl_actor_render(). It
* was two reasons, both filed upstream, and **libakgl 0.5.0 fixed one of them**:
* the default used to compute its destination height from the sprite's *width*,
* drawing a 24x21 Commodore sprite as a 24x24 square, and it now uses the
* height. That was libakgl defect 26.
*
* The remaining reason is the one that still requires this file: an akgl_Actor
* carries a single scalar `scale` applied to both axes, which cannot express
* SPRITE's separate x- and y-expand bits. libakgl records that as an open item
* and names this interpreter as the caller that needs it; the shape it takes --
* scale_x/scale_y beside scale, or replacing scale outright -- is a design
* decision over there rather than a patch. Until it lands, installing a function
* pointer is libakgl's own extension point for exactly this, so nothing is
* forked.
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
*/
#include <errno.h>
#include <string.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#include <akgl/util.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
/** @brief The colour conversion, same as src/graphics_akgl.c's. */
static SDL_Color to_sdl(akbasic_Color color)
{
SDL_Color out;
out.r = color.r;
out.g = color.g;
out.b = color.b;
out.a = color.a;
return out;
}
/** @brief Recover the backend's own state, or say that it has none. */
static akerr_ErrorContext *state_of(akbasic_SpriteBackend *self, akbasic_AkglSprites **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl sprite backend");
*dest = (akbasic_AkglSprites *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"akgl sprite backend has no state");
SUCCEED_RETURN(errctx);
}
/** @brief Recover the state and turn a BASIC sprite number into a slot index. */
static akerr_ErrorContext *slot_of(akbasic_SpriteBackend *self, int n, akbasic_AkglSprites **state, int *index)
{
PREPARE_ERROR(errctx);
PASS(errctx, state_of(self, state));
FAIL_ZERO_RETURN(errctx, (n >= 1 && n <= AKBASIC_MAX_SPRITES), AKBASIC_ERR_BOUNDS,
"Sprite %d is outside 1..%d", n, AKBASIC_MAX_SPRITES);
*index = n - 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Draw one BASIC sprite, in place of akgl_actor_render().
*
Take libakgl 0.5.0 and rename every symbol it namespaced 0.5.0 gives every exported symbol the akgl_ prefix, which is the first libakgl release to break this project's source rather than only its ABI. The soname goes to libakgl.so.0.5 and include/akbasic/akgl.h asserts the floor. What moved here: akgl_render_bind2d is akgl_render_2d_bind, akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the renderer, camera and window globals carry the prefix, and _akgl_renderer and _akgl_camera are akgl_default_renderer and akgl_default_camera. The renames were applied by site rather than by pattern, because renderer is also a parameter name in src/sprite_akgl.c and a struct member throughout src/frontend_akgl.c -- a substitution would have rewritten both without a word. That is the same trap upstream describes hitting, and it is worth knowing that the defect behind the rename was not cosmetic: an exported global called renderer collided with a test's own variable, the executable's definition preempted the library's, and every texture load in that suite failed while the suite passed. 0.5.0 also fixes libakgl defect 26, which was one of the two reasons src/sprite_akgl.c installs its own renderfunc: akgl_actor_render took its destination height from the sprite's width, drawing a 24x21 Commodore sprite as a 24x24 square. The renderfunc stays, because the other reason has not moved -- an actor carries one scalar scale and SPRITE has separate x- and y-expand bits -- but the comments and TODO.md deviation 40 no longer claim a defect that is fixed. Every documentation figure re-renders byte-identical under the new library, which is what says the sprite and drawing paths did not move underneath them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:01:43 -04:00
* Differs from the default in exactly one way now, noted at the top of this file:
* the two expansion bits scale the axes independently, where an actor's single
* `scale` cannot. Taking the destination height from the sprite's height used to
* be the second difference and is the default's behaviour as of libakgl 0.5.0 --
* the line below is no longer a correction, only agreement.
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
*
* Like the default, it skips rather than reports: an actor with no image, one
* that is hidden, one whose slot has been reused. A frame is not the place to
* fail.
*/
static akerr_ErrorContext *spr_render_actor(akgl_Actor *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
akgl_Sprite *sprite = NULL;
SDL_FRect src;
SDL_FRect dest;
int i = 0;
int found = -1;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL actor in sprite render");
if ( !obj->visible || obj->basechar == NULL ) {
SUCCEED_RETURN(errctx);
}
state = (akbasic_AkglSprites *)obj->actorData;
FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
"Sprite actor \"%s\" carries no backend state", obj->name);
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( state->actors[i] == obj ) {
found = i;
break;
}
}
if ( found < 0 ) {
SUCCEED_RETURN(errctx);
}
sprite = state->sprites[found];
if ( sprite == NULL || sprite->sheet == NULL || sprite->sheet->texture == NULL ) {
SUCCEED_RETURN(errctx);
}
Take libakgl 0.5.0 and rename every symbol it namespaced 0.5.0 gives every exported symbol the akgl_ prefix, which is the first libakgl release to break this project's source rather than only its ABI. The soname goes to libakgl.so.0.5 and include/akbasic/akgl.h asserts the floor. What moved here: akgl_render_bind2d is akgl_render_2d_bind, akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the renderer, camera and window globals carry the prefix, and _akgl_renderer and _akgl_camera are akgl_default_renderer and akgl_default_camera. The renames were applied by site rather than by pattern, because renderer is also a parameter name in src/sprite_akgl.c and a struct member throughout src/frontend_akgl.c -- a substitution would have rewritten both without a word. That is the same trap upstream describes hitting, and it is worth knowing that the defect behind the rename was not cosmetic: an exported global called renderer collided with a test's own variable, the executable's definition preempted the library's, and every texture load in that suite failed while the suite passed. 0.5.0 also fixes libakgl defect 26, which was one of the two reasons src/sprite_akgl.c installs its own renderfunc: akgl_actor_render took its destination height from the sprite's width, drawing a 24x21 Commodore sprite as a 24x24 square. The renderfunc stays, because the other reason has not moved -- an actor carries one scalar scale and SPRITE has separate x- and y-expand bits -- but the comments and TODO.md deviation 40 no longer claim a defect that is fixed. Every documentation figure re-renders byte-identical under the new library, which is what says the sprite and drawing paths did not move underneath them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:01:43 -04:00
PASS(errctx, akgl_spritesheet_coords_for_frame(sprite, &src, 0));
dest.x = obj->x - (akgl_camera != NULL ? akgl_camera->x : 0.0f);
dest.y = obj->y - (akgl_camera != NULL ? akgl_camera->y : 0.0f);
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
dest.w = (float32_t)sprite->width * (state->xexpand[found] ? 2.0f : 1.0f);
dest.h = (float32_t)sprite->height * (state->yexpand[found] ? 2.0f : 1.0f);
PASS(errctx, state->renderer->draw_texture(state->renderer, sprite->sheet->texture,
&src, &dest, 0, NULL, SDL_FLIP_NONE));
SUCCEED_RETURN(errctx);
}
/** @brief Tear down slot @p i, releasing its texture with it. */
static akerr_ErrorContext *release_slot(akbasic_AkglSprites *state, int i)
{
PREPARE_ERROR(errctx);
/*
* Actor first, then character, sprite and sheet. Each borrows the one below
* it without taking a reference, so releasing in the other order leaves a
* live object pointing at a zeroed pool slot.
*/
if ( state->actors[i] != NULL ) {
PASS(errctx, akgl_heap_release_actor(state->actors[i]));
state->actors[i] = NULL;
}
if ( state->characters[i] != NULL ) {
PASS(errctx, akgl_heap_release_character(state->characters[i]));
state->characters[i] = NULL;
}
if ( state->sprites[i] != NULL ) {
PASS(errctx, akgl_heap_release_sprite(state->sprites[i]));
state->sprites[i] = NULL;
}
if ( state->sheets[i] != NULL ) {
/* This is what destroys the texture: see akgl_heap_release_spritesheet. */
PASS(errctx, akgl_heap_release_spritesheet(state->sheets[i]));
state->sheets[i] = NULL;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Build slot @p i's object graph around a sheet that is already loaded.
*
* The names are fixed 128-byte buffers, not string literals, because
* akgl_sprite_initialize() and akgl_actor_initialize() memcpy a whole
* AKGL_*_MAX_NAME_LENGTH from whatever they are handed.
*/
static akerr_ErrorContext *build_slot(akbasic_AkglSprites *state, int i, int width, int height)
{
PREPARE_ERROR(errctx);
char spritename[AKGL_SPRITE_MAX_NAME_LENGTH];
char charname[AKGL_SPRITE_MAX_NAME_LENGTH];
char actorname[AKGL_ACTOR_MAX_NAME_LENGTH];
float32_t oldx = 0.0f;
float32_t oldy = 0.0f;
bool oldvisible = false;
if ( state->actors[i] != NULL ) {
oldx = state->actors[i]->x;
oldy = state->actors[i]->y;
oldvisible = state->actors[i]->visible;
}
memset(spritename, 0, sizeof(spritename));
memset(charname, 0, sizeof(charname));
memset(actorname, 0, sizeof(actorname));
SDL_snprintf(spritename, sizeof(spritename), "akbasic:sprite:%d", i + 1);
SDL_snprintf(charname, sizeof(charname), "akbasic:character:%d", i + 1);
SDL_snprintf(actorname, sizeof(actorname), "akbasic:actor:%d", i + 1);
PASS(errctx, akgl_heap_next_sprite(&state->sprites[i]));
PASS(errctx, akgl_sprite_initialize(state->sprites[i], spritename, state->sheets[i]));
state->sprites[i]->frames = 1;
state->sprites[i]->frameids[0] = 0;
state->sprites[i]->width = (uint32_t)width;
state->sprites[i]->height = (uint32_t)height;
PASS(errctx, akgl_heap_next_character(&state->characters[i]));
PASS(errctx, akgl_character_initialize(state->characters[i], charname));
PASS(errctx, akgl_character_sprite_add(state->characters[i], state->sprites[i], 0));
PASS(errctx, akgl_heap_next_actor(&state->actors[i]));
PASS(errctx, akgl_actor_initialize(state->actors[i], actorname));
PASS(errctx, akgl_actor_set_character(state->actors[i], charname));
state->actors[i]->state = 0;
state->actors[i]->actorData = state;
state->actors[i]->renderfunc = spr_render_actor;
/*
* Position and visibility survive a redefinition. SPRSAV changes what a
* sprite looks like, not where it is or whether it is on -- a program that
* animates by swapping patterns in a loop would otherwise have to re-issue
* SPRITE and MOVSPR after every frame.
*/
state->actors[i]->x = oldx;
state->actors[i]->y = oldy;
state->actors[i]->visible = oldvisible;
SUCCEED_RETURN(errctx);
}
/** @brief Take ownership of @p surface as slot @p i's image, replacing whatever was there. */
static akerr_ErrorContext *install_surface(akbasic_AkglSprites *state, int i, SDL_Surface *surface)
{
PREPARE_ERROR(errctx);
SDL_Texture *texture = NULL;
char sheetname[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
int width = 0;
int height = 0;
FAIL_ZERO_RETURN(errctx, (surface != NULL), AKERR_NULLPOINTER, "NULL surface for a sprite");
width = surface->w;
height = surface->h;
texture = SDL_CreateTextureFromSurface(state->renderer->sdl_renderer, surface);
FAIL_ZERO_RETURN(errctx, (texture != NULL), AKGL_ERR_SDL,
"Could not upload a sprite pattern: %s", SDL_GetError());
/*
* Nearest-neighbour. A sprite is a 24x21 bitmap and an expanded one is drawn
* at twice the size; smoothing it would turn a pixel into a smudge, which is
* not what anybody typing SPRSAV into a BASIC has in mind.
*/
SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST);
PASS(errctx, release_slot(state, i));
/*
* The sheet is assembled rather than initialized: akgl_spritesheet_initialize
* loads an image from a path, and this pattern came from bytes or from a
* saved region. Everything about it still matches what that function
* produces, so akgl_heap_release_spritesheet() tears it down the same way --
* which is what makes the texture's ownership one rule rather than two.
*/
PASS(errctx, akgl_heap_next_spritesheet(&state->sheets[i]));
memset(state->sheets[i], 0, sizeof(*state->sheets[i]));
memset(sheetname, 0, sizeof(sheetname));
SDL_snprintf(sheetname, sizeof(sheetname), "akbasic:sheet:%d", i + 1);
SDL_strlcpy(state->sheets[i]->name, sheetname, sizeof(state->sheets[i]->name));
state->sheets[i]->texture = texture;
state->sheets[i]->refcount = 1;
SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, state->sheets[i]->name, state->sheets[i]);
PASS(errctx, build_slot(state, i, width, height));
SUCCEED_RETURN(errctx);
}
/** @brief Write one unpacked pattern into @p surface, a set bit in @p fg and a clear one in @p bg. */
static akerr_ErrorContext *paint_pattern(SDL_Surface *surface, const uint8_t *pixels, akbasic_Color fg, akbasic_Color bg)
{
PREPARE_ERROR(errctx);
int x = 0;
int y = 0;
for ( y = 0; y < AKBASIC_SPRITE_HEIGHT; y++ ) {
for ( x = 0; x < AKBASIC_SPRITE_WIDTH; x++ ) {
akbasic_Color c = (pixels[(y * AKBASIC_SPRITE_WIDTH) + x] != 0 ? fg : bg);
FAIL_ZERO_RETURN(errctx,
SDL_WriteSurfacePixel(surface, x, y, c.r, c.g, c.b, c.a),
AKGL_ERR_SDL,
"Could not write sprite pixel %d,%d: %s", x, y, SDL_GetError());
}
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define(akbasic_SpriteBackend *self, int n, const uint8_t *pattern, int bytes, akbasic_Color fg, akbasic_Color bg)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_Surface *surface = NULL;
uint8_t pixels[AKBASIC_SPRITE_WIDTH * AKBASIC_SPRITE_HEIGHT];
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
PASS(errctx, akbasic_sprite_unpack(pattern, bytes, pixels));
surface = SDL_CreateSurface(AKBASIC_SPRITE_WIDTH, AKBASIC_SPRITE_HEIGHT,
SDL_PIXELFORMAT_RGBA32);
FAIL_ZERO_RETURN(errctx, (surface != NULL), AKGL_ERR_SDL,
"Could not create a sprite surface: %s", SDL_GetError());
ATTEMPT {
/*
* The pixel loop is its own function so that it can be reached with a
* single CATCH. Written inline it would need a FAIL inside two nested
* loops inside this ATTEMPT, and the _BREAK forms expand to a C break
* that escapes only the inner loop.
*/
CATCH(errctx, paint_pattern(surface, pixels, fg, bg));
CATCH(errctx, install_surface(state, index, surface));
} CLEANUP {
SDL_DestroySurface(surface);
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define_shape(akbasic_SpriteBackend *self, int n, int handle)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
FAIL_ZERO_RETURN(errctx, (state->graphics != NULL), AKBASIC_ERR_DEVICE,
"SPRSAV from a saved shape needs a graphics backend, and this host supplied none");
FAIL_ZERO_RETURN(errctx, (handle >= 0 && handle < state->graphics->shapecount),
AKBASIC_ERR_VALUE,
"Shape handle %d names no saved region", handle);
FAIL_ZERO_RETURN(errctx, (state->graphics->shapes[handle] != NULL), AKBASIC_ERR_VALUE,
"Shape handle %d has been released", handle);
PASS(errctx, install_surface(state, index, state->graphics->shapes[handle]));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define_file(akbasic_SpriteBackend *self, int n, const char *path, const char *root)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
akgl_String *resolved = NULL;
int index = 0;
bool missing = false;
PASS(errctx, slot_of(self, n, &state, &index));
FAIL_ZERO_RETURN(errctx, (path != NULL && path[0] != '\0'), AKBASIC_ERR_VALUE,
"SPRSAV was given an empty file name");
PASS(errctx, akgl_heap_next_string(&resolved));
ATTEMPT {
CATCH(errctx, akgl_string_initialize(resolved, NULL));
/*
* The same resolution a sprite document gets for the spritesheet it
* names: the working directory first, then the directory the file doing
* the naming lives in -- here, the directory the BASIC program was
* loaded from. A program stored beside its art therefore works whether
* it was launched from its own directory or from anywhere else.
*/
CATCH(errctx, akgl_path_relative((char *)(root != NULL ? root : "."),
(char *)path, resolved));
CATCH(errctx, release_slot(state, index));
CATCH(errctx, akgl_heap_next_spritesheet(&state->sheets[index]));
/*
* And the same load. The two frame-size arguments are dead upstream --
* akgl_spritesheet_initialize does not write them -- and a Commodore
* sprite has no frame grid anyway, so the image is one whole frame.
*/
CATCH(errctx, akgl_spritesheet_initialize(state->sheets[index], 0, 0,
(char *)&resolved->data));
FAIL_ZERO_BREAK(errctx, (state->sheets[index]->texture != NULL), AKGL_ERR_SDL,
"Loaded %s but it produced no texture", path);
SDL_SetTextureScaleMode(state->sheets[index]->texture, SDL_SCALEMODE_NEAREST);
/*
* The image's own size, not 24x21. A Commodore sprite is 24x21 because
* that is what the hardware could address; there is no reason to throw
* away art that is not sprite-shaped on a machine with no such limit.
*/
CATCH(errctx, build_slot(state, index,
state->sheets[index]->texture->w,
state->sheets[index]->texture->h));
} CLEANUP {
IGNORE(akgl_heap_release_string(resolved));
} PROCESS(errctx) {
} HANDLE(errctx, ENOENT) {
/*
* Recorded rather than raised here. Returning from inside a HANDLE block
* leaves FINISH's bookkeeping undone, so the flag is set and the new
* error is raised below where an ordinary return is safe.
*/
missing = true;
} FINISH(errctx, true);
FAIL_NONZERO_RETURN(errctx, missing, AKBASIC_ERR_VALUE, "No such sprite image: %s", path);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_show(akbasic_SpriteBackend *self, int n, bool visible)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
if ( state->actors[index] != NULL ) {
state->actors[index]->visible = visible;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_move(akbasic_SpriteBackend *self, int n, double x, double y)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
if ( state->actors[index] != NULL ) {
state->actors[index]->x = (float32_t)x;
state->actors[index]->y = (float32_t)y;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_configure(akbasic_SpriteBackend *self, int n, akbasic_Color color, bool xexpand, bool yexpand, bool behind)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_Color sdl = to_sdl(color);
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
state->xexpand[index] = xexpand;
state->yexpand[index] = yexpand;
/*
* Colour is a texture modulation rather than a repaint: a sprite defined
* from a pattern was drawn in the colour SPRSAV was given, and SPRITE's
* colour argument tints it. For a sprite loaded from an image that means a
* white image takes the colour and a coloured one is shaded by it, which is
* what texture modulation does everywhere else.
*/
if ( state->sheets[index] != NULL && state->sheets[index]->texture != NULL ) {
SDL_SetTextureColorMod(state->sheets[index]->texture, sdl.r, sdl.g, sdl.b);
}
/*
* Priority is not honoured. A C128 draws a low-priority sprite behind the
* bitmap screen; here the text layer and the drawing surface are the same
* render target and sprites are composited on top of it every frame, so
* there is nothing to go behind. Recorded in TODO.md section 5.
*/
(void)behind;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_shared_colors(akbasic_SpriteBackend *self, akbasic_Color c1, akbasic_Color c2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
/*
* Nothing to do yet, and saying so is better than pretending. Multicolour
* mode packs two bitmap bits per pixel and selects between the sprite's own
* colour and these two shared registers; SPRSAV here takes one bit per pixel,
* so there is no second bit to select with. The state is kept by the
* interpreter, RSPCOLOR reads it back, and the day a multicolour pattern
* format exists this is where it lands.
*/
(void)c1;
(void)c2;
SUCCEED_RETURN(errctx);
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
/**
* @brief Is slot @p i a thing that can be collided with at all?
*
* The three guards the pair loop used to repeat inline. A slot with no actor was
* never defined; a slot with no sprite has no frame and therefore no size; and an
* invisible sprite is out of the world entirely, which is the cheapest way a
* program has of switching one off without forgetting where it was.
*/
static bool slot_collidable(akbasic_AkglSprites *state, int i)
{
return (state->actors[i] != NULL
&& state->sprites[i] != NULL
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
&& state->actors[i]->visible
&& state->shapekind[i] != AKBASIC_SHAPE_NONE);
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
}
/**
* @brief Point slot @p i's proxy at where the sprite is and what size it is now.
*
* The shape is rebuilt every scan rather than cached against a dirty flag. It is
* derived from the sprite's frame and the two expansion bits, both of which a
* program may change at any statement, and building a box is a handful of
* arithmetic -- cheaper than being wrong about what invalidates it.
*
* **The mask override is the load-bearing line.** akgl_collision_shape_box()
* leaves a shape on the ACTOR layer responding to STATIC only, so that giving a
* town full of NPCs hitboxes does not make them shove each other around. That is
* the right default for a tile game and exactly wrong here: BASIC's collision is
* sprite-against-sprite first, and taking the library's default would make
* BUMP(1) report nothing at all, silently.
*/
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
/**
* @brief Record the shape SPRHIT asked for. The next scan builds it.
*
* Nothing is built here, and the reason is worth stating: a frame-fitting shape
* depends on the picture and the expansion bits, both of which may change after
* this call and before the next scan, so deriving it now would be deriving it
* from the wrong numbers. Zeroing the synced rectangle is what makes the next
* scan rebuild rather than recognise the position as unchanged and skip.
*/
static akerr_ErrorContext *spr_shape(akbasic_SpriteBackend *self, int n, int kind,
double x1, double y1, double x2, double y2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int i = 0;
PASS(errctx, slot_of(self, n, &state, &i));
state->shapekind[i] = kind;
state->shapex1[i] = (float32_t)x1;
state->shapey1[i] = (float32_t)y1;
state->shapex2[i] = (float32_t)x2;
state->shapey2[i] = (float32_t)y2;
state->shapeexplicit[i] = (x2 > x1 && y2 > y1);
memset(&state->syncedbox[i], 0, sizeof(state->syncedbox[i]));
SUCCEED_RETURN(errctx);
}
/**
* @brief The rectangle slot @p i collides with, in device pixels.
*
* Either what SPRHIT was given, offset to where the sprite is, or the whole
* drawn frame -- which is what a sprite nobody has shaped has always collided
* with, and is the default this must keep reproducing exactly.
*/
static void collision_box(akbasic_AkglSprites *state, int i, SDL_FRect *dest)
{
dest->x = state->actors[i]->x;
dest->y = state->actors[i]->y;
if ( state->shapeexplicit[i] ) {
dest->x += state->shapex1[i];
dest->y += state->shapey1[i];
dest->w = state->shapex2[i] - state->shapex1[i];
dest->h = state->shapey2[i] - state->shapey1[i];
return;
}
dest->w = (float32_t)state->sprites[i]->width * (state->xexpand[i] ? 2.0f : 1.0f);
dest->h = (float32_t)state->sprites[i]->height * (state->yexpand[i] ? 2.0f : 1.0f);
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
static akerr_ErrorContext AKERR_NOIGNORE *sync_proxy(akbasic_AkglSprites *state, int i,
SDL_FRect *box)
{
PREPARE_ERROR(errctx);
SDL_FRect body;
/*
* **Nothing to do if nothing moved.** The scan runs at the top of every
* interpreter step -- up to 256 times a rendered frame -- and a sprite moves
* at most once in that time, so the overwhelming majority of syncs would
* rewrite a proxy with what it already holds. Four float comparisons to find
* that out are far cheaper than the shape build and the sync they skip.
*
* Compared against the last synced rectangle rather than tracked with a dirty
* flag a verb sets. A flag would be smaller and would be wrong: this file's
* own header says a host game sees BASIC's sprites in libakgl's actor
* registry "and can do anything to them it can do to an actor", which
* includes moving one behind the interpreter's back. Comparing the answer
* cannot miss that; flagging the question can.
*/
if ( state->syncedbox[i].x == box->x && state->syncedbox[i].y == box->y
&& state->syncedbox[i].w == box->w && state->syncedbox[i].h == box->h ) {
SUCCEED_RETURN(errctx);
}
body.x = 0.0f;
body.y = 0.0f;
body.w = box->w;
body.h = box->h;
/*
* A zero-sized frame would be refused by the shape builder, and a sprite can
* legitimately have one before its sheet is loaded. Treat it as not
* collidable rather than raising: the program has not done anything wrong.
*/
if ( body.w <= 0.0f || body.h <= 0.0f ) {
SUCCEED_RETURN(errctx);
}
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
switch ( state->shapekind[i] ) {
case AKBASIC_SHAPE_CIRCLE:
/*
* Inscribed, and in the *smaller* of the two half-extents. A circle that
* fitted the larger one would poke out of the rectangle the program
* asked for, which is the opposite of what "give this sprite a circle"
* means to somebody looking at a disc drawn inside a square frame.
*/
PASS(errctx, akgl_collision_shape_circle(&state->shapes[i],
body.w / 2.0f, body.h / 2.0f,
(body.w < body.h ? body.w : body.h) / 2.0f,
0.0f));
break;
case AKBASIC_SHAPE_CAPSULE_X:
case AKBASIC_SHAPE_CAPSULE_Y:
PASS(errctx, akgl_collision_shape_capsule(&state->shapes[i], &body,
(uint8_t)state->shapekind[i], 0.0f));
break;
default:
PASS(errctx, akgl_collision_shape_box(&state->shapes[i], &body, 0.0f));
break;
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
state->shapes[i].collidemask = AKGL_COLLISION_LAYER_ACTOR | AKGL_COLLISION_LAYER_STATIC;
PASS(errctx, akgl_collision_proxy_sync(state->proxies[i], &state->shapes[i],
box->x, box->y, 0.0f));
state->syncedbox[i] = *box;
SUCCEED_RETURN(errctx);
}
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
/**
* @brief Run the scan if anything has changed, and leave both masks on the state.
*
* One worker behind two entry points, because both masks come out of the same
* pass and computing them separately would be computing the sprite boxes twice
* for one answer. `spr_collisions()` reports the sprite-against-sprite mask and
* `spr_solids()` the sprite-against-static one; either may be called first, and
* the cache below is what makes that true rather than merely usually true.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_scan(akbasic_AkglSprites *state)
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
{
PREPARE_ERROR(errctx);
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
akgl_Contact contact;
SDL_FRect box[AKBASIC_MAX_SPRITES];
bool live[AKBASIC_MAX_SPRITES];
bool synced[AKBASIC_MAX_SPRITES];
bool hit = false;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
int i = 0;
int j = 0;
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
live[i] = slot_collidable(state, i);
synced[i] = false;
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
memset(&box[i], 0, sizeof(box[i]));
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
if ( !live[i] ) {
continue;
}
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
collision_box(state, i, &box[i]);
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
}
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
/*
* **Nothing moved, so nothing can have changed.**
*
* The scan's inputs are exactly these boxes and which slots are collidable;
* static geometry invalidates the cache where it is registered. So if all
* eight match what the last scan saw, the answer is the answer it gave, and
* eight rectangle comparisons are far cheaper than recomputing it.
*
* This is what makes static geometry affordable. The scan runs at the top of
* every interpreter step -- up to 256 times a rendered frame -- and eight
* sprites against sixty-four rectangles is five hundred and twelve tests.
* That is fine once a frame and ruinous 256 times, and a sprite moves at
* most once in that window.
*
* It changes nothing a program can observe: the same mask comes back, so
* BUMP accumulates the same bits and the interrupt is raised on the same
* steps.
*/
if ( state->lastvalid ) {
bool same = true;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( live[i] != state->lastlive[i]
|| box[i].x != state->lastbox[i].x || box[i].y != state->lastbox[i].y
|| box[i].w != state->lastbox[i].w || box[i].h != state->lastbox[i].h ) {
same = false;
break;
}
}
if ( same ) {
SUCCEED_RETURN(errctx);
}
}
state->lastmask = 0;
state->lastsolidmask = 0;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
/*
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
* **Two phases, and the split is the whole performance story.**
*
* The obvious implementation -- sync all eight proxies, then run
* akgl_collision_test() on all twenty-eight pairs -- was measured at 984 ns a
* scan against 96 ns for the four-comparison loop it replaced. At 256 scans a
* frame that is 21% of a frame, and it bought nothing: the mask came back
* bit-identical and the contact was thrown away.
*
* So: reject on the bounding boxes first, in the four comparisons that were
* always here, and pay for the narrowphase only on a pair that survives.
* Nearly every scan of a real game rejects all twenty-eight, and a pair that
* does overlap is worth an exact answer. This is not a workaround for a slow
* library -- it is what a broad phase *is*, and akgl_collision_test() does the
* same thing internally with the proxies' own bounds before it commits to a
* solver.
*
* **The narrowphase still decides.** The box test only says "maybe"; the bit
* is set by what the library answers. That matters for a shape that is not a
* box -- a circle inscribed in a frame overlaps a smaller region than the
* frame does -- so the prefilter can over-report and must never be the final
* word. Today every shape is the whole frame and the two always agree, which
* is exactly why this is the moment to get the ordering right rather than
* the moment it starts to matter.
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
*
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
* AKGL_COLLISION_TEST_PLANAR because a Commodore sprite has no z. Without it
* the extrusion the shape builder chose takes part in the test and the
* minimum axis can come back as a depth along z, which no BASIC program can
* act on.
*
* Still box against box, not pixel against pixel: a C128's VIC-II collides on
* set pixels, so two sprites whose boxes overlap but whose art does not are
* reported as colliding here and would not be there. TODO.md section 5.
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
if ( !live[i] ) {
continue;
}
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
for ( j = i + 1; j < AKBASIC_MAX_SPRITES; j++ ) {
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
if ( !live[j] ) {
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
continue;
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
if ( !(box[i].x < box[j].x + box[j].w && box[j].x < box[i].x + box[i].w
&& box[i].y < box[j].y + box[j].h && box[j].y < box[i].y + box[i].h) ) {
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
continue;
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
/*
* PASS rather than CATCH throughout: this is a loop, and CATCH
* expands to a break, which would leave the remaining pairs untested
* and the mask half built.
*/
if ( !synced[i] ) {
PASS(errctx, sync_proxy(state, i, &box[i]));
synced[i] = true;
}
if ( !synced[j] ) {
PASS(errctx, sync_proxy(state, j, &box[j]));
synced[j] = true;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
}
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
hit = false;
PASS(errctx, akgl_collision_test(state->proxies[i], state->proxies[j],
AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
if ( hit ) {
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
state->lastmask |= (uint16_t)(1u << i);
state->lastmask |= (uint16_t)(1u << j);
}
}
}
/*
* Every live sprite against every registered rectangle, same shape: reject
* on the boxes, and only then ask the library. A wall is motionless, so its
* proxy was synced when SOLID registered it and there is nothing to refresh
* here.
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( !live[i] ) {
continue;
}
for ( j = 0; j < AKBASIC_MAX_SOLIDS; j++ ) {
if ( !state->solidactive[j] ) {
continue;
}
if ( !(box[i].x < state->solidbox[j].x + state->solidbox[j].w
&& state->solidbox[j].x < box[i].x + box[i].w
&& box[i].y < state->solidbox[j].y + state->solidbox[j].h
&& state->solidbox[j].y < box[i].y + box[i].h) ) {
continue;
}
if ( !synced[i] ) {
PASS(errctx, sync_proxy(state, i, &box[i]));
synced[i] = true;
}
hit = false;
PASS(errctx, akgl_collision_test(state->proxies[i], state->solidproxies[j],
AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
if ( hit ) {
state->lastsolidmask |= (uint16_t)(1u << i);
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
}
}
}
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
state->lastbox[i] = box[i];
state->lastlive[i] = live[i];
}
state->lastvalid = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief Register, replace or retire static rectangle @p id.
*
* A proxy with a **NULL owner**: there is no actor behind a wall, and libakgl's
* proxy carries the owner only so a resolver can push something. Nothing here
* resolves anything, so the field stays empty and the shape is what matters.
*
* Layers are the other half. A wall sits on #AKGL_COLLISION_LAYER_STATIC and
* responds to nothing, which is the asymmetry libakgl's masks exist for: the
* sprite's own `collidemask` includes STATIC, so a sprite finds a wall and two
* walls never test against each other. Sixty-four motionless rectangles
* therefore cost nothing per scan beyond the boxes a sprite is compared to.
*/
static akerr_ErrorContext *spr_solid(akbasic_SpriteBackend *self, int id, bool define,
double x1, double y1, double x2, double y2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_FRect body;
int i = 0;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (id >= 1 && id <= AKBASIC_MAX_SOLIDS), AKBASIC_ERR_BOUNDS,
"Static shape %d is outside 1..%d", id, AKBASIC_MAX_SOLIDS);
i = id - 1;
/*
* Anything that changes the geometry invalidates the cached answer. Cheapest
* possible correctness: the scan's own change detection watches the sprites,
* and this is the one input it cannot see moving.
*/
state->lastvalid = false;
if ( !define ) {
if ( state->solidproxies[i] != NULL ) {
PASS(errctx, akgl_heap_release_collision_proxy(state->solidproxies[i]));
state->solidproxies[i] = NULL;
}
state->solidactive[i] = false;
SUCCEED_RETURN(errctx);
}
body.x = 0.0f;
body.y = 0.0f;
body.w = (float32_t)(x2 - x1);
body.h = (float32_t)(y2 - y1);
PASS(errctx, akgl_collision_shape_box(&state->solidshapes[i], &body, 0.0f));
state->solidshapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
state->solidshapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
state->solidshapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
if ( state->solidproxies[i] == NULL ) {
/* Adjacent, with nothing between: the slot is free until initialize takes it. */
PASS(errctx, akgl_heap_next_collision_proxy(&state->solidproxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(state->solidproxies[i], NULL,
&state->solidshapes[i],
(float32_t)x1, (float32_t)y1, 0.0f));
} else {
PASS(errctx, akgl_collision_proxy_sync(state->solidproxies[i], &state->solidshapes[i],
(float32_t)x1, (float32_t)y1, 0.0f));
}
state->solidbox[i].x = (float32_t)x1;
state->solidbox[i].y = (float32_t)y1;
state->solidbox[i].w = body.w;
state->solidbox[i].h = body.h;
state->solidactive[i] = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_collisions(akbasic_SpriteBackend *self, uint16_t *mask)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in collisions");
PASS(errctx, run_scan(state));
*mask = state->lastmask;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_solids(akbasic_SpriteBackend *self, uint16_t *mask)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in solids");
PASS(errctx, run_scan(state));
*mask = state->lastsolidmask;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sprite_init_akgl(akbasic_SpriteBackend *obj, akbasic_AkglSprites *state, akgl_RenderBackend *renderer, akbasic_AkglGraphics *graphics)
{
PREPARE_ERROR(errctx);
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
int i = 0;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL && renderer != NULL),
AKERR_NULLPOINTER, "NULL argument in sprite_init_akgl");
/* Before anything else in libakgl, so every AKGL_ERR_* has a name to print. */
PASS(errctx, akgl_error_init());
memset(state, 0, sizeof(*state));
state->renderer = renderer;
state->graphics = graphics;
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
/*
* The eight collision proxies, claimed here and held for the life of the
* backend.
*
* Up front rather than on demand because the pool is shared: a host game
* embedding this interpreter draws its own shaped actors from the same
* AKGL_MAX_HEAP_COLLISION_PROXY. Claiming late would mean a collision scan
* discovering mid-game that a host had taken the last slot, and reporting it
* against whichever BASIC line was unlucky. Claiming here makes it an
* initialization failure that names the pool, before a program has run.
*
* A default box shape so a proxy is never initialized from an uninitialized
* shape -- libakgl fixed exactly that defect in 3a6569e, where filing a proxy
* under half-extents that had not been written yet was invisible to its
* tests and visible only to a memory checker. Real geometry arrives on the
* first sync_proxy(); this is only ever a placeholder.
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
SDL_FRect placeholder = { 0.0f, 0.0f, 1.0f, 1.0f };
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
/*
* A box fitting the frame, which is what every sprite collided with
* before SPRHIT existed and is what one nobody has shaped must go on
* collided with.
*/
state->shapekind[i] = AKBASIC_SHAPE_BOX;
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
PASS(errctx, akgl_collision_shape_box(&state->shapes[i], &placeholder, 0.0f));
/*
* Acquire and initialize adjacent, with nothing between them: the slot
* is free until initialize takes the reference, and libakgl's heap says
* so where next_collision_proxy is declared.
*/
PASS(errctx, akgl_heap_next_collision_proxy(&state->proxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(state->proxies[i], NULL,
&state->shapes[i], 0.0f, 0.0f, 0.0f));
}
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
memset(obj, 0, sizeof(*obj));
obj->self = state;
obj->define = spr_define;
obj->define_shape = spr_define_shape;
obj->define_file = spr_define_file;
obj->show = spr_show;
obj->move = spr_move;
obj->configure = spr_configure;
obj->shared_colors = spr_shared_colors;
obj->collisions = spr_collisions;
Let a program say what part of a sprite collides `SPRHIT n, kind [,x1, y1, x2, y2]` gives a sprite a collision shape: a box, a circle inscribed in it, or a capsule. `RSPHIT(n, f)` reads it back in SPRHIT's own argument order, the way RSPRITE and RSPPOS already do, and needs no device because it answers from interpreter state. The rectangle is two corners measured from the sprite's top-left, in device pixels -- the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take. A dialect with two spellings for a rectangle is one nobody can write from memory. Omit it and the shape fits whatever the picture turned out to be, which is what a sprite loaded from a file needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what that was. **A sprite nobody has shaped collides with its whole frame, expansion bits included, exactly as before.** That is a promise rather than a convenience, and it has its own test: the same two sprites in the same two places, once with no SPRHIT and once with a four-pixel box, reporting a collision and then not. Named SPRHIT rather than SPRSHAPE because "shape" already means "a region SSHAPE saved" in this dialect, in this very chapter -- `SPRSAV A$, 1` takes one -- and a reader who typed `SPRSHAPE A$, 1` would have had every reason to. Both names, and RSPHIT, were grepped against every label in docs/, examples/ and both corpora first: a bare word is a label here, so a verb and a label share one namespace and taking a name a checked-in listing already uses would break it silently. `SPRHIT n, 0` takes a sprite out of collision while leaving it on the screen -- the ghost, the flashing invulnerable player, the pickup already taken. Hiding it with `SPRITE n, 0` stops it colliding too, and is what you want when it should not be seen either. The circle answers the complaint chapter 8 already ships a figure of. That figure shows two discs whose *boxes* touch at a corner while the artwork is nowhere near, and `BUMP(1)` reporting a collision; two `SPRHIT n, 2` and it stops. The test asserts both halves so the figure's caption stays true. `tests/verbs_table.c` caught RSPHIT filed after RSPPOS rather than before it, which is the sorted-table test doing exactly the job it exists for. Docs: a new section in chapter 8, rows in the verb and function references in alphabetical order, and chapter 13's "collision is by bounding box" becomes "collision is by shape" with the addition named. 111 with akgl, 110 without, and the artwork breakout still runs clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:01:33 -04:00
obj->shape = spr_shape;
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
obj->solid = spr_solid;
obj->solids = spr_solids;
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sprite_akgl_render(akbasic_SpriteBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int i = 0;
PASS(errctx, state_of(obj, &state));
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( state->actors[i] == NULL ) {
continue;
}
/*
* PASS rather than CATCH: this is a loop, and CATCH expands to a break.
* Through the actor's own renderfunc rather than calling spr_render_actor
* directly, so a host that has replaced it gets what it asked for.
*/
PASS(errctx, state->actors[i]->renderfunc(state->actors[i]));
}
SUCCEED_RETURN(errctx);
}
void akbasic_sprite_akgl_shutdown(akbasic_SpriteBackend *obj)
{
akbasic_AkglSprites *state = NULL;
int i = 0;
if ( obj == NULL || obj->self == NULL ) {
return;
}
state = (akbasic_AkglSprites *)obj->self;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
IGNORE(release_slot(state, i));
Answer sprite collision through libakgl's narrowphase `spr_collisions()` computed axis-aligned overlaps itself, because at libakgl 0.7.0 there was nothing to delegate to: `akgl_collide_rectangles()` has a documented corner-containment defect and the physics backend's `collide` slot raised "Not implemented". 0.8.0 brought a real narrowphase, and this moves onto it. The mask is bit-identical and every test from the previous commit passes **unmodified**, which was the gate this stage had to clear -- including the two that were written to be hard to satisfy. Edge-to-edge is still not a collision, so a tile-aligned program is unaffected. The cross-shaped overlap is still reported, which is the one that could have regressed: it is the case `akgl_collide_rectangles()` gets wrong and the reason the hand-written loop existed, and `akgl_collision_test()`'s box path gets it right. What it buys is the contact -- a normal, a penetration depth and a point -- which four comparisons cannot produce. Nothing consumes it yet; that is the next commit. It is here now because the mask and the contact come out of the same test, and computing them in two places would be two things to keep in step. **The first attempt was ten times slower and the benchmark caught it.** Syncing all eight proxies and running the narrowphase on all twenty-eight pairs measured 984 ns a scan against 96 ns for the loop it replaced -- 21% of a frame at 256 scans a frame -- to produce a mask that was bit-identical and a contact that was thrown away. Two changes fixed it, and both are what a broad phase *is* rather than workarounds for a slow library: - **Reject on the bounding boxes first.** The four comparisons that were always here now decide which pairs are worth an exact answer. The narrowphase still decides the bit -- the box test only says "maybe", which will matter the moment a shape is not the whole frame. - **Do not sync a proxy that has not moved.** The scan runs at the top of every interpreter step and a sprite moves at most once in that time, so almost every sync would rewrite a proxy with what it already holds. Compared against the last synced rectangle rather than flagged by the verbs, because a host game can move a BASIC sprite through the actor registry and a flag would miss that. Measured after: 54.9 ns at eight sprites spread out, which is *faster* than the 96.3 ns it replaced -- boxes are now built eight times a scan instead of fifty-six. The number that matters is the new benchmark row for the arrangement `examples/breakout/sprites/breakout.bas` actually has, which is 211.8 ns, or 4.5% of a frame. That game reaches it because two of its eight sprites are the screen -- a captured HUD strip and a captured play field -- so the field's box covers everything and those pairs can never be rejected. Roughly double the old cost, for contacts. Recorded in MAINTENANCE.md with the two synthetic extremes either side of it as a bracket. The eight proxies are claimed once at init and held, so exhaustion of the pool shared with an embedding host is an initialization failure that names the pool rather than a collision scan refusing halfway through somebody's game. The shape is built before the proxy is spawned from it and the acquire sits adjacent to the initialize, which are two traps libakgl hit itself and documents. `tests/akgl_backends.c` now tears the sprite backend down between cases. It never did, and got away with it while init claimed nothing; eight proxies apiece across twenty cases is a hundred and sixty against a pool of a hundred and twenty-eight. A host releases what it took, and so does the harness. Both games run forty seconds headless with no error line. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 09:48:11 -04:00
/*
* The proxies go back too. They are not registered with any partitioner,
* so there is nothing to unlink first -- which is the one ordering trap
* here, and the reason worth writing down: releasing a proxy that *is*
* registered leaves stale cell entries holding a pool index, and the
* damage only shows when the slot is handed out again.
*/
if ( state->proxies[i] != NULL ) {
IGNORE(akgl_heap_release_collision_proxy(state->proxies[i]));
state->proxies[i] = NULL;
}
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
}
Collide sprites with rectangles that are not sprites `SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id` retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and `DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and mean *sprite met static geometry*. **This is the thing eight sprite slots made impossible.** A wall of bricks wants sixty, so until now a program could only collide with one by doing the arithmetic itself against its own array -- which is exactly what both breakout listings do, at about two hundred lines between them. A rectangle costs no sprite slot. The id is the **program's own number**, 1 to 64, not a minted handle. That is the whole trick for "which brick did I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps onto `B#(I#)` with no lookup, and retiring a broken brick is `SOLID I#`. `COLLISION 2` was refused with "sprite-to-background collision needs the screen read back every frame", which was true of the question a C128 asks -- a sprite against the bitmap's set pixels. `SOLID` gives this interpreter a background made of rectangles instead, which is the same question in a form it can answer. Same move `SPRSAV` made when it learned to take an image path. `AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented "COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`. **There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's uniform grid keeps its cell heads, cell size and origin in file-scope statics, so it is one index per process -- and `akgl_collision_world_init()` ends in a `reset()` that memsets those heads *and* calls `akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its own collision world would have destroyed every registration that game had made, on the first `SOLID` a script ran. So the geometry is indexed by an ordinary array here and pairs go straight to `akgl_collision_test()`, which needs no world. At sixty-four rectangles that is the right answer anyway; libakgl's own numbers put a naive sweep at 0.7% of a frame at sixty-four objects. **The scan now short-circuits when nothing has moved**, and that is what makes any of it affordable. Its inputs are the sprites' boxes, which slots are collidable, and the static geometry; if none changed the answer cannot have. A frame runs one full scan and 255 cached ones. Eight sprites against sixty-four rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256 times. The benchmark was rewritten to say which path it is timing, because with the cache in place a loop that only calls the scan measures the short circuit and nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0% it cost before any of this work**, with static geometry and contacts added on top. `NEW` retires the rectangles, where it cannot undefine a sprite pattern: there *is* an entry point for this one, so leaving them would be a choice, and the wrong one -- a rectangle is invisible, so one left behind by a deleted program is an unexplainable collision in the next. `CLR` leaves them alone. `tests/sprite_verbs.c` gains the whole second path against the mock and its `COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2 arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains the end-to-end version, including a full sixty-four-rectangle wall so the proxy budget is exercised at its ceiling and the pool has to come back intact, and the sixty-fifth refused by name. A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than `akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:25:35 -04:00
for ( i = 0; i < AKBASIC_MAX_SOLIDS; i++ ) {
if ( state->solidproxies[i] != NULL ) {
IGNORE(akgl_heap_release_collision_proxy(state->solidproxies[i]));
state->solidproxies[i] = NULL;
}
}
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
}