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);
|
|
|
|
|
}
|
|
|
|
|
|
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
|
|
|
/**
|
|
|
|
|
* @brief Keep @p contact for slot @p i if it is deeper than what is there.
|
|
|
|
|
*
|
|
|
|
|
* A sprite may be in several overlaps at once -- a ball touching two bricks at a
|
|
|
|
|
* corner -- and only one can be reported. The deepest is the one that has to be
|
|
|
|
|
* undone, so it is the one worth keeping.
|
|
|
|
|
*
|
|
|
|
|
* The normal comes out of libakgl pointing out of the *other* shape and toward
|
|
|
|
|
* this one, which is the direction a program wants: move along it by the depth
|
|
|
|
|
* and you are clear.
|
|
|
|
|
*/
|
|
|
|
|
static void record_contact(akbasic_AkglSprites *state, int i, int what, int other,
|
|
|
|
|
akgl_Contact *contact)
|
|
|
|
|
{
|
|
|
|
|
akbasic_Contact *dest = &state->contacts[i];
|
|
|
|
|
|
|
|
|
|
if ( state->hascontact[i] && dest->depth >= (double)contact->depth ) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
dest->what = what;
|
|
|
|
|
dest->other = other;
|
|
|
|
|
dest->nx = (double)contact->nx;
|
|
|
|
|
dest->ny = (double)contact->ny;
|
|
|
|
|
dest->depth = (double)contact->depth;
|
|
|
|
|
dest->px = (double)contact->px;
|
|
|
|
|
dest->py = (double)contact->py;
|
|
|
|
|
/*
|
|
|
|
|
* The minimum translation axis, from the normal rather than from an overlap
|
|
|
|
|
* rectangle: whichever component is larger is the axis the shapes are least
|
|
|
|
|
* far through, and therefore the one to reverse. Computed here because doing
|
|
|
|
|
* it in BASIC means comparing two floats, which is exactly where this
|
|
|
|
|
* dialect's left-operand rule bites.
|
|
|
|
|
*/
|
|
|
|
|
dest->axis = ((contact->nx < 0.0f ? -contact->nx : contact->nx)
|
|
|
|
|
>= (contact->ny < 0.0f ? -contact->ny : contact->ny)) ? 1 : 2;
|
|
|
|
|
state->hascontact[i] = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static akerr_ErrorContext *spr_contact(akbasic_SpriteBackend *self, int n,
|
|
|
|
|
akbasic_Contact *dest, bool *found)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
akbasic_AkglSprites *state = NULL;
|
|
|
|
|
int i = 0;
|
|
|
|
|
|
|
|
|
|
PASS(errctx, slot_of(self, n, &state, &i));
|
|
|
|
|
FAIL_ZERO_RETURN(errctx, (dest != NULL && found != NULL), AKERR_NULLPOINTER,
|
|
|
|
|
"NULL argument in contact");
|
|
|
|
|
*found = state->hascontact[i];
|
|
|
|
|
if ( *found ) {
|
|
|
|
|
*dest = state->contacts[i];
|
|
|
|
|
}
|
|
|
|
|
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;
|
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
|
|
|
memset(state->hascontact, 0, sizeof(state->hascontact));
|
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
|
|
|
|
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);
|
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
|
|
|
record_contact(state, i, 1, j + 1, &contact);
|
|
|
|
|
/*
|
|
|
|
|
* And the mirror for the other sprite. The normal points out of
|
|
|
|
|
* b toward a, so b's own copy has to be negated or both sprites
|
|
|
|
|
* would be told to move the same way.
|
|
|
|
|
*/
|
|
|
|
|
contact.nx = -contact.nx;
|
|
|
|
|
contact.ny = -contact.ny;
|
|
|
|
|
record_contact(state, j, 1, i + 1, &contact);
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* 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);
|
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
|
|
|
record_contact(state, i, 2, j + 1, &contact);
|
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;
|
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
|
|
|
obj->contact = spr_contact;
|
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
|
|
|
}
|