diff --git a/MAINTENANCE.md b/MAINTENANCE.md index cf2925d..c1769a7 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -411,33 +411,49 @@ that have not moved. Is that worth changing to a per-frame cadence? Measured at `RelWithDebInfo`, scale 10, best of 5: -| Row | ns/op | as 256 scans of a 1.20 ms frame | -|---|---|---| -| `spr_collisions`, 0 sprites | 16.6 | 0.35% | -| `spr_collisions`, 2 sprites, spread out | 29.6 | 0.63% | -| `spr_collisions`, 4 sprites, spread out | 37.4 | 0.80% | -| `spr_collisions`, 8 sprites, spread out | 54.9 | 1.2% | -| **`spr_collisions`, breakout's own layout** | **211.8** | **4.5%** | -| `spr_collisions`, 8 sprites, all overlapping | 800.4 | 17% | -| one rendered frame, 8 sprites + full text grid | 1,203,923 | — | +| Row | ns/op | +|---|---| +| full scan, 0 sprites | 59.2 | +| full scan, 2 sprites, spread out | 153.3 | +| full scan, 4 sprites, spread out | 229.0 | +| full scan, 8 sprites, spread out | 366.4 | +| **full scan, breakout's own layout** | **590.6** | +| full scan, 8 sprites, all overlapping | 1145.4 | +| **cached scan, nothing moved** | **40.0** | +| one rendered frame, 8 sprites + full text grid | 1,191,947 | -**The answer is no.** Even the row that matters most — the arrangement -`examples/breakout/sprites/breakout.bas` actually has, which is the most demanding program -here — is 4.5% of a frame, and it only reaches that 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 the bounding-box reject can never throw those pairs out. The frame is dominated by the text -layer repainting every row it owns, which `TODO.md` already records as the real rendering cost. +**Two paths, and which one a call takes is the whole story.** The scan short-circuits when no +sprite has moved and no static geometry has changed since the last one — its inputs are exactly +those, so if none changed the answer cannot have. A frame runs **one** full scan and 255 cached +ones. The `full scan` rows nudge a sprite before each call to defeat that, so they measure a +scan that actually ran; they therefore include a `MOVSPR`-equivalent, which is honest for +comparison because a real frame pays that too. -So the per-step cadence stays. It is what makes a collision report describe where the sprites -have just been moved to rather than where they were (`src/runtime.c`, above the service call), -and buying a few percent of a frame is not worth changing when a handler fires for every program -that already works. Recorded here rather than argued again. +So for `examples/breakout/sprites/breakout.bas`, the most demanding program here: + + 590.6 + (255 × 40.0) = 10.8 µs against a 1.19 ms frame — 0.91% + +**That is less than it cost before any of this work**, when it was 96.3 ns unconditionally on +all 256 steps, or 24.7 µs, or 2.0% — and it now includes static geometry and produces contacts. +The short circuit is what pays for both: eight sprites against sixty-four rectangles is five +hundred and twelve tests, fine once a frame and ruinous 256 times. + +**So the per-step cadence stays**, and the question of moving it to per-frame is answered "no" +twice over. It is what makes a collision report describe where the sprites have just been moved +to rather than where they were (`src/runtime.c`, above the service call); it changes when a +handler fires for every program that already works; and the cost it was supposed to save is now +under one percent of a frame. Recorded here rather than argued again. **Read the two synthetic extremes as a bracket, not as an answer.** "Spread out" is every pair rejected on four comparisons; "all overlapping" is every pair going through to the narrowphase, which is a state no real program sits in. Real programs land between them, and the breakout row is there so the question "between them *where*" has an answer. +Breakout reaches the high end of that bracket for a reason worth knowing: 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 the bounding-box reject can never throw those pairs out. That is +`TODO.md` §9 item 9, and fixing it would take this row down as a side effect. + **Read a benchmark as a gap between two rows of the same run, not as an absolute.** libakgl's `PERFORMANCE.md` records a whole laptop reading 15% high on a later run, including rows nothing had touched. diff --git a/docs/08-sprites.md b/docs/08-sprites.md index ee5a6d6..928ec3d 100644 --- a/docs/08-sprites.md +++ b/docs/08-sprites.md @@ -204,6 +204,60 @@ also stops it colliding, and is what you want when it should not be seen either. `RSPHIT(n, f)` reads it back, in `SPRHIT`'s own argument order — 0 the kind, then 1 to 4 for the two corners. It needs no sprite device, the way `RSPPOS` does not. +## Colliding with things that are not sprites + +There are eight sprites. A wall of bricks wants sixty, and until now the only way to +collide with one was to do the arithmetic yourself against your own array. + +`SOLID` registers a rectangle the interpreter will collide sprites against, and it costs +no sprite slot: + +```basic norun +SOLID id, x1, y1, x2, y2 register or replace rectangle id +SOLID id retire it +SOLID retire them all +``` + +`id` is 1 to 64 and it is **your own number**. 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 straight onto +`B#(I#)` with no lookup. Retiring a broken brick is the same verb, `SOLID I#`. + +Rectangles are in window pixels — the coordinates `BOX` draws in and `MOVSPR` positions in, +with `SCALE` not applying, exactly as for a sprite. So a brick drawn with +`BOX 1, X#, Y#, X# + 24, Y# + 10` is registered with the same four expressions. + +The bare form retiring everything is what a level change wants, and it follows `TRAP`, +`COLLISION` and `DCLOSE`, which all read absence as "off". + +**`COLLISION 2` is how you hear about it.** On a C128 type 2 is a sprite against the set +pixels of the bitmap screen; here it is a sprite against the rectangles `SOLID` registered, +which is the same question in a form this interpreter can answer. `BUMP(2)` is its mask, +and it is a **separate** accumulator from `BUMP(1)` — a sprite hitting a wall never sets a +bit in the sprite-to-sprite mask, so a program that only cares about one of them is not +disturbed by the other. + +```basic norun +10 COLLISION 2, WALL +20 FOR I# = 1 TO 60 +30 SOLID I#, BX#(I#), BY#(I#), BX#(I#) + 24, BY#(I#) + 10 +40 NEXT I# +... +100 LABEL WALL +110 M# = BUMP(2) +120 IF (M# AND 1) = 0 THEN RETURN +130 REM ... the ball hit something ... +140 RETURN +``` + +Sixty-four is the ceiling, and it is a real budget rather than a round number: these come +out of a pool of collision proxies shared with whatever game this interpreter is embedded +in, and eight sprites plus sixty-four rectangles is most of akbasic's share of it. The +sixty-fifth is refused by name. + +`NEW` retires them all. A rectangle is invisible, so one left behind by a deleted program +would be an unexplainable collision in the next; `CLR` leaves them alone, because it clears +variables and a rectangle is not one. + ## Reading state back | Function | Gives | diff --git a/docs/11-verb-reference.md b/docs/11-verb-reference.md index 743a7e9..49c38c9 100644 --- a/docs/11-verb-reference.md +++ b/docs/11-verb-reference.md @@ -22,7 +22,7 @@ for the reasoning in each case. | `CIRCLE` | `CIRCLE src, x, y, rx, ry [,...]` | Draw an ellipse, arc or polygon. | | `CLR` | `CLR` | Drop every variable and function, keeping the program. | | `COLLECT` | `COLLECT` | **Refused.** Validates a disk's block allocation map. | -| `COLLISION` | `COLLISION 1 [,target]` | Call a subroutine when sprites collide. No target disarms it. | +| `COLLISION` | `COLLISION type [,target]` | Call a subroutine when sprites collide. No target disarms it. | | `COLOR` | `COLOR src, index` | Bind a colour source to a palette index, 1 to 16. | | `CONCAT` | `CONCAT "a", "b"` | Append file `a` to file `b`. | | `CONT` | `CONT` | Resume a program stopped by `STOP`. | @@ -90,6 +90,7 @@ for the reasoning in each case. | `SCNCLR` | `SCNCLR` | Clear the text screen. | | `SCRATCH` | `SCRATCH "name"` | Delete a file. | | `SLEEP` | `SLEEP seconds` | Pause. Holds the program, not the host. | +| `SOLID` | `SOLID [id [,x1, y1, x2, y2]]` | Register static collision geometry a sprite can hit. No rectangle retires it; no arguments retires them all. See Chapter 8. | | `SOUND` | `SOUND v, freq, dur [,...]` | Play a tone on a voice. Does not block. | | `SPRCOLOR` | `SPRCOLOR [c1] [,c2]` | Set the two shared multicolour registers. | | `SPRHIT` | `SPRHIT n, kind [,x1, y1, x2, y2]` | Give a sprite a collision shape. Kind 0 none, 1 box, 2 circle, 3 or 4 capsule. No rectangle fits the frame. See Chapter 8. | diff --git a/docs/13-differences.md b/docs/13-differences.md index 42b1136..23898e8 100644 --- a/docs/13-differences.md +++ b/docs/13-differences.md @@ -177,7 +177,12 @@ interpreter's error code, which bears no relation to a Commodore error number. P narrows that to a box, a circle or a capsule. None of them is pixel-exact. - **`SPRHIT` and `RSPHIT` are an addition.** BASIC 7.0 has `COLLISION` and `BUMP` and nothing else, and nothing about them changes an existing program. -- **Only collision type 1 exists.** Types 2 and 3 are refused by name. +- **Collision types 1 and 2 exist.** Type 2 means the rectangles `SOLID` registered, not a + screen read back — a C128 collides a sprite against the bitmap's set pixels and this + cannot, so the question is asked against geometry instead. Type 3 is still refused; + there is no light pen. +- **`SOLID` is an addition.** BASIC 7.0 has no static collision geometry at all, and it is + what lets a program collide with something that is not one of the eight sprites. - **Priority and multicolour are recorded but not drawn.** - **`SPRDEF` is out of scope.** diff --git a/include/akbasic/akgl.h b/include/akbasic/akgl.h index 970c0e1..30c4be5 100644 --- a/include/akbasic/akgl.h +++ b/include/akbasic/akgl.h @@ -288,6 +288,51 @@ typedef struct float32_t shapex2[AKBASIC_MAX_SPRITES]; float32_t shapey2[AKBASIC_MAX_SPRITES]; bool shapeexplicit[AKBASIC_MAX_SPRITES]; + + /** + * SOLID's static geometry: a proxy and a shape per registered rectangle. + * + * **Not registered with any partitioner, and there is no + * `akgl_CollisionWorld` here at all.** libakgl's uniform grid keeps its cell + * heads, its cell size and its origin in file-scope statics + * (`deps/libakgl/src/collision_grid.c`), 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 that has its own collision world would destroy every + * registration that game had made, on the first `SOLID` a script ran. + * + * So the geometry is indexed here, by an ordinary array, and pairs go + * straight to `akgl_collision_test()` -- which takes two positioned proxies + * and needs no world. At sixty-four rectangles that is the right answer + * anyway: libakgl's own measurements put a naive all-pairs sweep at 0.7% of + * a frame at sixty-four objects, and a broad phase is bookkeeping that only + * repays itself well above that. + */ + akgl_CollisionProxy *solidproxies[AKBASIC_MAX_SOLIDS]; + akgl_CollisionShape solidshapes[AKBASIC_MAX_SOLIDS]; + SDL_FRect solidbox[AKBASIC_MAX_SOLIDS]; + bool solidactive[AKBASIC_MAX_SOLIDS]; + + /** + * What the last scan saw, so an unchanged scan can answer without redoing + * the work. + * + * The scan runs at the top of every interpreter step -- up to 256 times a + * rendered frame -- and its inputs are the sprites' boxes, which sprite + * slots are collidable, and the static geometry. A sprite moves at most once + * in that time. If none of those changed, the answer cannot have changed + * either, and eight rectangle comparisons say so far more cheaply than + * recomputing it. + * + * This is what makes static geometry affordable at all: eight sprites + * against sixty-four rectangles is five hundred and twelve tests, which is + * fine once a frame and is not fine two hundred and fifty-six times. + */ + SDL_FRect lastbox[AKBASIC_MAX_SPRITES]; + bool lastlive[AKBASIC_MAX_SPRITES]; + uint16_t lastmask; + uint16_t lastsolidmask; + bool lastvalid; } akbasic_AkglSprites; /** diff --git a/include/akbasic/sprite.h b/include/akbasic/sprite.h index 82a7e07..0bbe8f6 100644 --- a/include/akbasic/sprite.h +++ b/include/akbasic/sprite.h @@ -52,6 +52,19 @@ /** @brief One past the last kind, for the range check. */ #define AKBASIC_SHAPE_LAST 5 +/** + * @brief Static collision rectangles a program can register with SOLID. + * + * Sixty-four, which is the smallest power of two that holds a sixty-brick wall + * with headroom. The ceiling matters because these come out of libakgl's + * collision proxy pool, which is shared with whatever host this interpreter is + * embedded in: eight sprites plus sixty-four solids is seventy-two of + * #AKGL_MAX_HEAP_COLLISION_PROXY, and the rest is the host's. + */ +#ifndef AKBASIC_MAX_SOLIDS +#define AKBASIC_MAX_SOLIDS 64 +#endif + /** @brief Width of a Commodore sprite in pixels. */ #define AKBASIC_SPRITE_WIDTH 24 /** @brief Height of a Commodore sprite in pixels. */ @@ -121,6 +134,23 @@ typedef struct bool shapeexplicit; } akbasic_Sprite; +/** + * @brief One rectangle registered by SOLID, as BASIC sees it. + * + * The id is the array index plus one and is the program's own number, not a + * minted handle. A brick in a sixty-brick wall has a natural index; making the + * program carry one back out of the interpreter would be inventing a second + * numbering for something that already has one. + */ +typedef struct +{ + bool active; + double x1; + double y1; + double x2; + double y2; +} akbasic_Solid; + /** * @brief The sprite verbs' own state, which lives on the runtime. * @@ -133,9 +163,11 @@ typedef struct typedef struct { akbasic_Sprite sprites[AKBASIC_MAX_SPRITES]; + akbasic_Solid solids[AKBASIC_MAX_SOLIDS]; int sharedcolor1; /* SPRCOLOR's two multicolour registers, 1-16 */ int sharedcolor2; uint16_t bumped; /* bit n-1 set when sprite n has collided */ + uint16_t bumpedsolid; /* and when it has met static geometry */ int64_t lastservicems; /* when MOVSPR's continuous motion last moved */ } akbasic_SpriteState; @@ -226,6 +258,30 @@ typedef struct akbasic_SpriteBackend */ akerr_ErrorContext AKERR_NOIGNORE *(*shape)(struct akbasic_SpriteBackend *self, int n, int kind, double x1, double y1, double x2, double y2); + /** + * Register, replace or retire static collision geometry @p id. + * + * @p define false retires it and the rectangle is ignored. Rectangles are in + * window pixels -- the coordinates `BOX` draws in and `MOVSPR` positions in, + * with `SCALE` not applying, exactly as for a sprite. + * + * Optional, like `shape`. A backend that withholds it makes `SOLID` refuse + * by name. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*solid)(struct akbasic_SpriteBackend *self, int id, bool define, + double x1, double y1, double x2, double y2); + /** + * Report which sprites are overlapping static geometry. + * + * Bit n-1 is set when sprite n overlaps at least one rectangle `solid` + * registered. Answered from the same pass as `collisions`, so the two are + * always about the same instant whichever order they are called in. + * + * Optional. A runtime whose backend withholds it reports no static + * collisions rather than refusing, exactly as one with no backend at all + * reports none -- a step is not a statement and has no line to blame. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*solids)(struct akbasic_SpriteBackend *self, uint16_t *mask); } akbasic_SpriteBackend; /** diff --git a/src/runtime_housekeeping.c b/src/runtime_housekeeping.c index fbb4c6b..773a3f5 100644 --- a/src/runtime_housekeeping.c +++ b/src/runtime_housekeeping.c @@ -123,6 +123,18 @@ akerr_ErrorContext *akbasic_cmd_new(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, PASS(errctx, obj->sprites->show(obj->sprites, (int)i + 1, false)); } } + /* + * **Static geometry does go back to the device**, where a sprite pattern + * cannot. There is an entry point that retires one, so leaving them would be + * a choice rather than a limitation -- and the wrong choice: a wall is + * invisible, so one left behind by a deleted program is an unexplainable + * collision in the next. + */ + if ( obj->sprites != NULL && obj->sprites->solid != NULL ) { + for ( i = 0; i < AKBASIC_MAX_SOLIDS; i++ ) { + PASS(errctx, obj->sprites->solid(obj->sprites, (int)i + 1, false, 0.0, 0.0, 0.0, 0.0)); + } + } /* * The line counter goes home too. Without this a NEW typed at the REPL diff --git a/src/runtime_sprite.c b/src/runtime_sprite.c index 99fcefe..5b33621 100644 --- a/src/runtime_sprite.c +++ b/src/runtime_sprite.c @@ -498,6 +498,7 @@ akerr_ErrorContext *akbasic_cmd_collision(akbasic_Runtime *obj, akbasic_ASTLeaf akbasic_ASTLeaf *handler = NULL; akbasic_Value *value = NULL; int type = 0; + int source = AKBASIC_INTERRUPT_SPRITE; (void)lval; (void)rval; FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, @@ -513,22 +514,23 @@ akerr_ErrorContext *akbasic_cmd_collision(akbasic_Runtime *obj, akbasic_ASTLeaf FAIL_ZERO_RETURN(errctx, (type >= 1 && type <= 3), AKBASIC_ERR_BOUNDS, "COLLISION: type %d is outside 1..3", type); /* - * Type 1 only. Sprite-to-background would need the whole render target read - * back and compared against each sprite every frame, and a light pen has no - * meaning on a machine with no light pen; both are refused by name rather - * than silently accepted and never fired. TODO.md section 5. + * Types 1 and 2 both exist now. A C128's type 2 is a sprite against the set + * pixels of the bitmap screen, which was refused here because reading the + * render target back every frame is not affordable -- but SOLID gives this + * interpreter a background made of rectangles instead of pixels, and that is + * the same question asked in a form it can answer. The same move SPRSAV made + * when it learned to take an image path. + * + * Type 3 stays refused. There is still no light pen. */ - FAIL_ZERO_RETURN(errctx, (type == 1), AKBASIC_ERR_DEVICE, - "COLLISION type %d is not implemented: %s", - type, - (type == 2 - ? "sprite-to-background collision needs the screen read back every frame" - : "there is no light pen")); + FAIL_ZERO_RETURN(errctx, (type == 1 || type == 2), AKBASIC_ERR_DEVICE, + "COLLISION type %d is not implemented: there is no light pen", type); + source = (type == 2 ? AKBASIC_INTERRUPT_BACKGROUND : AKBASIC_INTERRUPT_SPRITE); handler = arg->next; if ( handler == NULL ) { /* No target disarms it, which is how a C128 turns a handler off. */ - PASS(errctx, akbasic_runtime_disarm_interrupt(obj, AKBASIC_INTERRUPT_SPRITE)); + PASS(errctx, akbasic_runtime_disarm_interrupt(obj, source)); SUCCEED_TRUE(obj, dest); SUCCEED_RETURN(errctx); } @@ -544,7 +546,7 @@ akerr_ErrorContext *akbasic_cmd_collision(akbasic_Runtime *obj, akbasic_ASTLeaf */ if ( handler->leaftype == AKBASIC_LEAF_IDENTIFIER && akbasic_leaf_first_subscript(handler) == NULL ) { - PASS(errctx, akbasic_runtime_arm_interrupt(obj, AKBASIC_INTERRUPT_SPRITE, + PASS(errctx, akbasic_runtime_arm_interrupt(obj, source, 0, handler->identifier)); SUCCEED_TRUE(obj, dest); SUCCEED_RETURN(errctx); @@ -553,7 +555,7 @@ akerr_ErrorContext *akbasic_cmd_collision(akbasic_Runtime *obj, akbasic_ASTLeaf PASS(errctx, akbasic_runtime_evaluate(obj, handler, &value)); FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE, "COLLISION expected a line number or a label"); - PASS(errctx, akbasic_runtime_arm_interrupt(obj, AKBASIC_INTERRUPT_SPRITE, + PASS(errctx, akbasic_runtime_arm_interrupt(obj, source, value->intval, NULL)); SUCCEED_TRUE(obj, dest); SUCCEED_RETURN(errctx); @@ -563,6 +565,7 @@ akerr_ErrorContext *akbasic_collision_service(akbasic_Runtime *obj) { PREPARE_ERROR(errctx); uint16_t mask = 0; + uint16_t solidmask = 0; FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in collision_service"); @@ -570,6 +573,19 @@ akerr_ErrorContext *akbasic_collision_service(akbasic_Runtime *obj) SUCCEED_RETURN(errctx); } PASS(errctx, obj->sprites->collisions(obj->sprites, &mask)); + /* + * Static geometry is a second question with a second accumulator and a + * second handler, answered from the same scan the device just ran. A backend + * that cannot do it withholds the entry point and reports nothing, the same + * way a runtime with no backend at all does. + */ + if ( obj->sprites->solids != NULL ) { + PASS(errctx, obj->sprites->solids(obj->sprites, &solidmask)); + } + if ( solidmask != 0 ) { + obj->sprite_state.bumpedsolid |= solidmask; + PASS(errctx, akbasic_runtime_raise_interrupt(obj, AKBASIC_INTERRUPT_BACKGROUND)); + } if ( mask == 0 ) { SUCCEED_RETURN(errctx); } @@ -644,8 +660,8 @@ akerr_ErrorContext *akbasic_fn_bump(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, "NULL argument in BUMP"); PASS(errctx, nth_number(obj, expr, "BUMP", 0, &type)); - FAIL_ZERO_RETURN(errctx, (type == 1), AKBASIC_ERR_DEVICE, - "BUMP type %" PRId64 " is not implemented; only sprite-to-sprite collision is", + FAIL_ZERO_RETURN(errctx, (type == 1 || type == 2), AKBASIC_ERR_DEVICE, + "BUMP type %" PRId64 " is not implemented; sprite-to-sprite is 1 and static geometry is 2", type); /* @@ -653,8 +669,13 @@ akerr_ErrorContext *akbasic_fn_bump(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, * answerable at all: without it a program that polls in a loop would see the * same collision forever. */ - reported = obj->sprite_state.bumped; - obj->sprite_state.bumped = 0; + if ( type == 2 ) { + reported = obj->sprite_state.bumpedsolid; + obj->sprite_state.bumpedsolid = 0; + } else { + reported = obj->sprite_state.bumped; + obj->sprite_state.bumped = 0; + } PASS(errctx, integer_result(obj, (int64_t)reported, dest)); SUCCEED_RETURN(errctx); } @@ -706,6 +727,68 @@ static akerr_ErrorContext *float_result(akbasic_Runtime *obj, double value, akba SUCCEED_RETURN(errctx); } +/* ----------------------------------------------------------------- SOLID -- */ + +akerr_ErrorContext *akbasic_cmd_solid(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_Solid *solid = NULL; + double args[5]; + int count = 0; + int id = 0; + int i = 0; + + (void)lval; (void)rval; + FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, + "NULL argument in SOLID"); + PASS(errctx, require_sprites(obj, "SOLID")); + FAIL_ZERO_RETURN(errctx, (obj->sprites->solid != NULL), AKBASIC_ERR_DEVICE, + "SOLID needs a sprite device that can carry static collision geometry, and this one cannot"); + PASS(errctx, akbasic_args_numbers(obj, expr, "SOLID", args, 5, &count)); + FAIL_ZERO_RETURN(errctx, (count == 0 || count == 1 || count == 5), AKBASIC_ERR_SYNTAX, + "SOLID takes nothing, an id, or an id and four corners, not %d arguments", + count); + + /* + * No arguments clears the lot, which is how `TRAP`, `COLLISION` and `DCLOSE` + * all read absence -- and is the verb a level change wants. + */ + if ( count == 0 ) { + for ( i = 0; i < AKBASIC_MAX_SOLIDS; i++ ) { + if ( !obj->sprite_state.solids[i].active ) { + continue; + } + memset(&obj->sprite_state.solids[i], 0, sizeof(obj->sprite_state.solids[i])); + /* PASS rather than CATCH: this is a loop. */ + PASS(errctx, obj->sprites->solid(obj->sprites, i + 1, false, 0.0, 0.0, 0.0, 0.0)); + } + SUCCEED_RETURN(errctx); + } + + id = (int)args[0]; + FAIL_ZERO_RETURN(errctx, (id >= 1 && id <= AKBASIC_MAX_SOLIDS), AKBASIC_ERR_BOUNDS, + "SOLID: %d is outside 1..%d", id, AKBASIC_MAX_SOLIDS); + solid = &obj->sprite_state.solids[id - 1]; + + if ( count == 1 ) { + memset(solid, 0, sizeof(*solid)); + PASS(errctx, obj->sprites->solid(obj->sprites, id, false, 0.0, 0.0, 0.0, 0.0)); + SUCCEED_RETURN(errctx); + } + + FAIL_ZERO_RETURN(errctx, (args[3] > args[1] && args[4] > args[2]), AKBASIC_ERR_VALUE, + "SOLID: (%g, %g) to (%g, %g) is not a rectangle with a positive width and height", + args[1], args[2], args[3], args[4]); + solid->active = true; + solid->x1 = args[1]; + solid->y1 = args[2]; + solid->x2 = args[3]; + solid->y2 = args[4]; + PASS(errctx, obj->sprites->solid(obj->sprites, id, true, + solid->x1, solid->y1, solid->x2, solid->y2)); + SUCCEED_RETURN(errctx); +} + /* ---------------------------------------------------------------- SPRHIT -- */ akerr_ErrorContext *akbasic_cmd_sprhit(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) diff --git a/src/sprite_akgl.c b/src/sprite_akgl.c index 4062640..cf0ee5a 100644 --- a/src/sprite_akgl.c +++ b/src/sprite_akgl.c @@ -625,10 +625,18 @@ static akerr_ErrorContext AKERR_NOIGNORE *sync_proxy(akbasic_AkglSprites *state, SUCCEED_RETURN(errctx); } -static akerr_ErrorContext *spr_collisions(akbasic_SpriteBackend *self, uint16_t *mask) +/** + * @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) { PREPARE_ERROR(errctx); - akbasic_AkglSprites *state = NULL; akgl_Contact contact; SDL_FRect box[AKBASIC_MAX_SPRITES]; bool live[AKBASIC_MAX_SPRITES]; @@ -637,19 +645,52 @@ static akerr_ErrorContext *spr_collisions(akbasic_SpriteBackend *self, uint16_t int i = 0; int j = 0; - PASS(errctx, state_of(self, &state)); - FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in collisions"); - *mask = 0; - for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) { live[i] = slot_collidable(state, i); synced[i] = false; + memset(&box[i], 0, sizeof(box[i])); if ( !live[i] ) { continue; } collision_box(state, i, &box[i]); } + /* + * **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; + /* * **Two phases, and the split is the whole performance story.** * @@ -713,11 +754,143 @@ static akerr_ErrorContext *spr_collisions(akbasic_SpriteBackend *self, uint16_t PASS(errctx, akgl_collision_test(state->proxies[i], state->proxies[j], AKGL_COLLISION_TEST_PLANAR, &contact, &hit)); if ( hit ) { - *mask |= (uint16_t)(1u << i); - *mask |= (uint16_t)(1u << j); + state->lastmask |= (uint16_t)(1u << i); + state->lastmask |= (uint16_t)(1u << j); } } } + + /* + * Every live sprite against every registered rectangle, same shape: reject + * on the boxes, and only then ask the library. A wall is motionless, so its + * proxy was synced when SOLID registered it and there is nothing to refresh + * here. + */ + for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) { + if ( !live[i] ) { + continue; + } + for ( j = 0; j < AKBASIC_MAX_SOLIDS; j++ ) { + if ( !state->solidactive[j] ) { + continue; + } + if ( !(box[i].x < state->solidbox[j].x + state->solidbox[j].w + && state->solidbox[j].x < box[i].x + box[i].w + && box[i].y < state->solidbox[j].y + state->solidbox[j].h + && state->solidbox[j].y < box[i].y + box[i].h) ) { + continue; + } + if ( !synced[i] ) { + PASS(errctx, sync_proxy(state, i, &box[i])); + synced[i] = true; + } + hit = false; + PASS(errctx, akgl_collision_test(state->proxies[i], state->solidproxies[j], + AKGL_COLLISION_TEST_PLANAR, &contact, &hit)); + if ( hit ) { + state->lastsolidmask |= (uint16_t)(1u << i); + } + } + } + + 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; SUCCEED_RETURN(errctx); } @@ -784,6 +957,8 @@ akerr_ErrorContext *akbasic_sprite_init_akgl(akbasic_SpriteBackend *obj, akbasic obj->shared_colors = spr_shared_colors; obj->collisions = spr_collisions; obj->shape = spr_shape; + obj->solid = spr_solid; + obj->solids = spr_solids; SUCCEED_RETURN(errctx); } @@ -831,4 +1006,10 @@ void akbasic_sprite_akgl_shutdown(akbasic_SpriteBackend *obj) state->proxies[i] = NULL; } } + 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; + } + } } diff --git a/src/verbs.c b/src/verbs.c index 0cb6892..b5b0010 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -154,6 +154,7 @@ static const akbasic_Verb VERBS[] = { { "SHR", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shr }, { "SIN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_sin }, { "SLEEP", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sleep }, + { "SOLID", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_solid }, { "SOUND", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sound }, { "SPC", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_spc }, { "SPRCOLOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sprcolor }, diff --git a/src/verbs.h b/src/verbs.h index 7bba13c..873af98 100644 --- a/src/verbs.h +++ b/src/verbs.h @@ -90,6 +90,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_on(struct akbasic_Runtime *obj, a akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_collision(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_movspr(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sprcolor(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_solid(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sprhit(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sprite(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sprsav(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); diff --git a/tests/akgl_backends.c b/tests/akgl_backends.c index a28ee52..1fe0cca 100644 --- a/tests/akgl_backends.c +++ b/tests/akgl_backends.c @@ -844,6 +844,99 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_shapes(void) SUCCEED_RETURN(errctx); } +/** @brief Run a program and report the static-geometry mask. */ +static akerr_ErrorContext AKERR_NOIGNORE *solidmask_for(const char *source, uint16_t *dest) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, start_runtime(source)); + TEST_REQUIRE_STR(OUTPUT, ""); + PASS(errctx, SPRITES.solids(&SPRITES, dest)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief SOLID puts a collidable rectangle on the field without spending a sprite. + * + * This is the point of the whole exercise. A brick is not a sprite -- there are + * eight and a wall wants sixty -- so until now a program could only collide with + * one by doing the arithmetic itself against its own array. Now it registers a + * rectangle and the interpreter answers. + */ +static akerr_ErrorContext AKERR_NOIGNORE *test_static_geometry(void) +{ + PREPARE_ERROR(errctx); + uint16_t mask = 0; + + /* A 24x21 sprite at (100, 100) squarely inside a rectangle. */ + PASS(errctx, solidmask_for("10 DIM P#(63)\n" + "20 FOR I# = 0 TO 62\n" + "30 P#(I#) = 255\n" + "40 NEXT I#\n" + "50 SPRSAV P#, 1\n" + "60 SPRITE 1, 1\n" + "70 SOLID 7, 90, 90, 140, 140\n" + "80 MOVSPR 1, 100, 100\n", &mask)); + TEST_REQUIRE_INT(mask, 0x01); + stop_runtime(); + + /* Moved clear of it, nothing. */ + PASS(errctx, solidmask_for("10 DIM P#(63)\n" + "20 FOR I# = 0 TO 62\n" + "30 P#(I#) = 255\n" + "40 NEXT I#\n" + "50 SPRSAV P#, 1\n" + "60 SPRITE 1, 1\n" + "70 SOLID 7, 90, 90, 140, 140\n" + "80 MOVSPR 1, 200, 200\n", &mask)); + TEST_REQUIRE_INT(mask, 0); + stop_runtime(); + + /* + * Retired, and the sprite has not moved. This is the assertion that catches + * a proxy released without its registration going with it -- the shape would + * still be found and the mask would still come back set. + */ + PASS(errctx, solidmask_for("10 DIM P#(63)\n" + "20 FOR I# = 0 TO 62\n" + "30 P#(I#) = 255\n" + "40 NEXT I#\n" + "50 SPRSAV P#, 1\n" + "60 SPRITE 1, 1\n" + "70 SOLID 7, 90, 90, 140, 140\n" + "80 MOVSPR 1, 100, 100\n" + "90 SOLID 7\n", &mask)); + TEST_REQUIRE_INT(mask, 0); + stop_runtime(); + + /* + * A full wall registered, walked into, and cleared -- sixty-four of them, so + * the proxy budget is exercised at its ceiling and the pool has to come back + * intact afterwards or the next case fails. + */ + PASS(errctx, solidmask_for("10 DIM P#(63)\n" + "20 FOR I# = 0 TO 62\n" + "30 P#(I#) = 255\n" + "40 NEXT I#\n" + "50 SPRSAV P#, 1\n" + "60 SPRITE 1, 1\n" + "70 FOR I# = 1 TO 64\n" + "80 X# = I# * 30\n" + "90 SOLID I#, X#, 0, X# + 20, 20\n" + "100 NEXT I#\n" + "110 MOVSPR 1, 300, 5\n", &mask)); + TEST_REQUIRE_INT(mask, 0x01); + stop_runtime(); + + /* And the sixty-fifth is refused by name rather than exhausting the pool. */ + PASS(errctx, start_runtime("10 SOLID 65, 0, 0, 10, 10\n")); + TEST_REQUIRE(strstr(OUTPUT, "outside 1..64") != NULL, + "SOLID 65 should be refused, got \"%s\"", OUTPUT); + stop_runtime(); + + SUCCEED_RETURN(errctx); +} + int main(void) { PREPARE_ERROR(errctx); @@ -913,6 +1006,7 @@ int main(void) CATCH(errctx, test_collision_pairs()); CATCH(errctx, test_collision_cross_shape()); CATCH(errctx, test_sprite_shapes()); + CATCH(errctx, test_static_geometry()); } CLEANUP { if ( font != NULL ) { TTF_CloseFont(font); diff --git a/tests/collision_perf.c b/tests/collision_perf.c index 4b1b2d8..59d52fd 100644 --- a/tests/collision_perf.c +++ b/tests/collision_perf.c @@ -121,6 +121,26 @@ static void sprite_program(char *dest, size_t size, int n, bool stacked) } } +/** + * @brief Move sprite 1 a pixel, then scan. One full scan, no cache hit. + * + * The scan short-circuits when no sprite has moved since the last one, which is + * what 255 of every 256 calls a frame do -- so a loop that only calls the scan + * measures the short circuit and nothing else. Nudging a sprite first is what + * makes a row mean "a scan that actually ran". + */ +static float32_t bench_nudge = 0.0f; + +static akerr_ErrorContext AKERR_NOIGNORE *move_and_scan(uint16_t *mask) +{ + PREPARE_ERROR(errctx); + + bench_nudge = (bench_nudge > 0.5f ? 0.0f : 1.0f); + PASS(errctx, SPRITES.move(&SPRITES, 1, 20.0 + (double)bench_nudge, 20.0)); + PASS(errctx, SPRITES.collisions(&SPRITES, mask)); + SUCCEED_RETURN(errctx); +} + /** * @brief The arrangement examples/breakout/sprites/breakout.bas actually has. * @@ -162,7 +182,17 @@ static akerr_ErrorContext AKERR_NOIGNORE *bench_breakout_shaped(void) "200 MOVSPR 7, 600, 400\n" "210 MOVSPR 8, 200, 150\n")); - bench_start("spr_collisions, breakout's own layout", "call", 0.0); + bench_start("full scan, breakout's own layout", "call", 0.0); + BENCH_LOOP(inner, i, iterations, move_and_scan(&mask)); + bench_stop((uint64_t)iterations); + PASS(errctx, inner); + + /* + * And the other path, on the same population: what the other 255 calls a + * frame cost once the first has done the work. This row is the reason the + * per-step cadence is affordable at all. + */ + bench_start("cached scan, nothing moved", "call", 0.0); BENCH_LOOP(inner, i, iterations, SPRITES.collisions(&SPRITES, &mask)); bench_stop((uint64_t)iterations); PASS(errctx, inner); @@ -184,10 +214,10 @@ static akerr_ErrorContext AKERR_NOIGNORE *bench_scan(int n, bool stacked) sprite_program(source, sizeof(source), n, stacked); PASS(errctx, load(source)); - snprintf(name, sizeof(name), "spr_collisions, %d sprites, %s", n, + snprintf(name, sizeof(name), "full scan, %d sprites, %s", n, (stacked ? "all overlapping" : "spread out")); bench_start(name, "call", 0.0); - BENCH_LOOP(inner, i, iterations, SPRITES.collisions(&SPRITES, &mask)); + BENCH_LOOP(inner, i, iterations, move_and_scan(&mask)); bench_stop((uint64_t)iterations); PASS(errctx, inner); SUCCEED_RETURN(errctx); diff --git a/tests/mockdevice.h b/tests/mockdevice.h index 3a9bbc7..d42fd89 100644 --- a/tests/mockdevice.h +++ b/tests/mockdevice.h @@ -56,6 +56,7 @@ typedef struct /* Sprites */ uint16_t collisions; /* what the next collisions() call reports */ + uint16_t solidcollisions; /* and what the next solids() call reports */ int patternbytes[AKBASIC_MAX_SPRITES]; /* bytes the last define() carried, per sprite */ } akbasic_MockDevice; @@ -428,6 +429,40 @@ static akerr_ErrorContext *mock_spr_collisions(akbasic_SpriteBackend *self, uint SUCCEED_RETURN(errctx); } +static akerr_ErrorContext *mock_spr_solids(akbasic_SpriteBackend *self, uint16_t *mask) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in solids"); + *mask = MOCK.solidcollisions; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_spr_shape(akbasic_SpriteBackend *self, int n, int kind, + double x1, double y1, double x2, double y2) +{ + PREPARE_ERROR(errctx); + (void)self; + + mock_log("sprhit %d,%d,%g,%g,%g,%g\n", n, kind, x1, y1, x2, y2); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_spr_solid(akbasic_SpriteBackend *self, int id, bool define, + double x1, double y1, double x2, double y2) +{ + PREPARE_ERROR(errctx); + (void)self; + + if ( !define ) { + mock_log("solid %d off\n", id); + SUCCEED_RETURN(errctx); + } + mock_log("solid %d,%g,%g,%g,%g\n", id, x1, y1, x2, y2); + SUCCEED_RETURN(errctx); +} + /* ---------------------------------------------------------------- fixture -- */ /** @brief Reset the recorder and populate all three vtables. */ @@ -485,6 +520,9 @@ static void mock_devices_init(void) MOCK_SPRITES.configure = mock_spr_configure; MOCK_SPRITES.shared_colors = mock_spr_shared_colors; MOCK_SPRITES.collisions = mock_spr_collisions; + MOCK_SPRITES.solids = mock_spr_solids; + MOCK_SPRITES.shape = mock_spr_shape; + MOCK_SPRITES.solid = mock_spr_solid; } /** @brief Set what the next collisions() call will report. Bit n-1 is sprite n. */ @@ -494,6 +532,13 @@ static void mock_set_collisions(uint16_t mask) MOCK.collisions = mask; } +/** @brief Set what the next solids() call will report. Bit n-1 is sprite n. */ +__attribute__((unused)) +static void mock_set_solid_collisions(uint16_t mask) +{ + MOCK.solidcollisions = mask; +} + /** @brief Prime the input backend with keystrokes GET will drain in order. */ __attribute__((unused)) static void mock_push_keys(const char *keys) diff --git a/tests/sprite_verbs.c b/tests/sprite_verbs.c index bcca983..2c83570 100644 --- a/tests/sprite_verbs.c +++ b/tests/sprite_verbs.c @@ -350,9 +350,32 @@ static void test_collision_arming(void) "COLLISION with no target should disarm"); harness_stop(); - TEST_REQUIRE_OK(run_program("10 COLLISION 2, 100\n")); - TEST_REQUIRE(strstr(HARNESS_OUTPUT, "read back every frame") != NULL, - "COLLISION 2 should be refused with a reason, got \"%s\"", HARNESS_OUTPUT); + /* + * Type 2 arms a *second* handler, on its own interrupt source, and leaves + * type 1's alone. It used to be refused -- a C128's type 2 is a sprite + * against the bitmap screen's set pixels, which needs the render target read + * back every frame -- and SOLID is what turned it into a question this + * interpreter can answer, against rectangles instead of pixels. + */ + TEST_REQUIRE_OK(run_program("10 COLLISION 1, 100\n" + "20 COLLISION 2, 200\n")); + TEST_REQUIRE_STR(HARNESS_OUTPUT, ""); + TEST_REQUIRE(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].armed, + "COLLISION 1 should still have armed the sprite interrupt"); + TEST_REQUIRE_INT(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].line, 100); + TEST_REQUIRE(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_BACKGROUND].armed, + "COLLISION 2 should have armed the background interrupt"); + TEST_REQUIRE_INT(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_BACKGROUND].line, 200); + harness_stop(); + + /* And disarming one leaves the other armed. */ + TEST_REQUIRE_OK(run_program("10 COLLISION 1, 100\n" + "20 COLLISION 2, 200\n" + "30 COLLISION 2\n")); + TEST_REQUIRE(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].armed, + "disarming type 2 should not have disarmed type 1"); + TEST_REQUIRE(!HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_BACKGROUND].armed, + "COLLISION 2 with no target should disarm"); harness_stop(); TEST_REQUIRE_OK(run_program("10 COLLISION 3, 100\n")); @@ -407,6 +430,70 @@ static void test_collision_fires(void) harness_stop(); } +/** + * @brief SOLID registers static geometry, and COLLISION 2 fires on meeting it. + * + * The whole second path, against the mock: the device is told about a rectangle, + * the scan reports a sprite touching one, the service accumulates it on its own + * accumulator and raises its own interrupt, and BUMP(2) reports and clears + * without disturbing BUMP(1). + */ +static void test_solid_geometry(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, NULL, NULL, NULL, + &MOCK_SPRITES)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "1 COLLISION 2, 5\n" + "2 SOLID 7, 100, 100, 140, 120\n" + "3 PRINT \"MAIN\"\n" + "4 GOTO 3\n" + "5 PRINT \"WALL \" + BUMP(2)\n" + "6 PRINT \"SPRITES \" + BUMP(1)\n" + "7 RETURN\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 3)); + TEST_REQUIRE(strstr(MOCK.log, "solid 7,100,100,140,120") != NULL, + "SOLID should have reached the device, log was \"%s\"", MOCK.log); + + /* Sprite 1 meets a wall; nothing meets another sprite. */ + mock_set_solid_collisions(0x01); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 2)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "WALL 1") != NULL, + "the type 2 handler should have reported sprite 1, got \"%s\"", HARNESS_OUTPUT); + + mock_set_solid_collisions(0); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 2)); + /* The two accumulators are separate: BUMP(1) saw nothing. */ + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "SPRITES 0") != NULL, + "a static collision should not set BUMP(1), got \"%s\"", HARNESS_OUTPUT); + TEST_REQUIRE_INT(HARNESS_RUNTIME.sprite_state.bumpedsolid, 0); + harness_stop(); + + /* SOLID with no arguments retires every one of them. */ + TEST_REQUIRE_OK(run_program("10 SOLID 3, 0, 0, 10, 10\n" + "20 SOLID 4, 20, 20, 30, 30\n" + "30 SOLID\n")); + TEST_REQUIRE(strstr(MOCK.log, "solid 3 off") != NULL, + "bare SOLID should have retired 3, log was \"%s\"", MOCK.log); + TEST_REQUIRE(strstr(MOCK.log, "solid 4 off") != NULL, + "bare SOLID should have retired 4, log was \"%s\"", MOCK.log); + harness_stop(); + + /* Out of range, and a rectangle that is not one. */ + TEST_REQUIRE_OK(run_program("10 SOLID 999, 0, 0, 10, 10\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside 1..") != NULL, + "SOLID 999 should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 SOLID 1, 10, 10, 10, 20\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "not a rectangle") != NULL, + "a zero-width SOLID should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + /** @brief The readbacks answer from interpreter state, so they need no device. */ static void test_readbacks(void) { @@ -518,6 +605,7 @@ int main(void) test_sprsav_refuses_to_write(); test_collision_arming(); test_collision_fires(); + test_solid_geometry(); test_readbacks(); test_no_device(); test_new_clears_sprites();