Commit Graph

10 Commits

Author SHA1 Message Date
802bbcc17a Collide sprites with rectangles that are not sprites
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m19s
akbasic CI Build / sanitizers (push) Failing after 4m33s
akbasic CI Build / coverage (push) Failing after 3m41s
akbasic CI Build / akgl_build (push) Failing after 21s
akbasic CI Build / mutation_test (push) Failing after 3m29s
`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
f005b88980 Let a program say what part of a sprite collides
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m20s
akbasic CI Build / sanitizers (push) Failing after 4m31s
akbasic CI Build / coverage (push) Failing after 3m40s
akbasic CI Build / akgl_build (push) Failing after 22s
akbasic CI Build / mutation_test (push) Failing after 3m28s
`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
f06c53110d 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
d61e352f36 Test the collision scan, and measure what it costs
`spr_collisions()` had no test. `tests/sprite_verbs.c` drives the collision path
end to end but through a mock backend, so the real overlap arithmetic in
`src/sprite_akgl.c` could have changed what `BUMP(1)` reports for every program
in existence and the suite would still have printed 110/110. That gap is closed
here, before anything touches the arithmetic, so that there is a *before* to
compare an *after* against.

Six cases against the real akgl backend: nothing defined, two sprites
overlapping, edge-to-edge, a hidden sprite, and the x-expand bit doubling the box
that collides rather than only the one that draws. Edge-to-edge earns its place
-- the test is a strict `<`, a tile-aligned program puts sprites there
constantly, and a replacement answering "touching" instead of "overlapping"
would change every one of them silently.

**The seventh is the cross-shaped overlap**, and it is the one to watch. A tall
thin sprite crossing a short wide one overlaps without either rectangle holding a
corner of the other; `akgl_collide_rectangles()` is documented as answering "no"
there, which is why `src/sprite_akgl.c` does the four comparisons itself rather
than calling it. Two further assertions stop that test passing by accident: each
sprite is moved clear along the axis it is supposed to be short on, so a sprite
that came out the wrong size fails rather than quietly reporting an ordinary
overlap.

`tests/collision_perf.c` answers the question nobody had measured. The service
runs at the top of every interpreter *step* and the frontend takes 256 steps per
rendered frame, so a busy program scans up to 256 times a frame over sprites that
have not moved. At RelWithDebInfo, scale 10, best of 5: the scan is 96.3 ns at
eight overlapping sprites and 19.4 ns at none, against a rendered frame of
1.17 ms. **256 scans is 24.7 us, or 2.1% of a frame, in the pathological case,
and 0.42% for a program with no sprites.**

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, and 2% of a
frame in a case no real program reaches is not worth changing when a handler
fires for every program that already works. The numbers and that conclusion are
in `MAINTENANCE.md` so it does not get re-argued.

The benchmark borrows libakgl's `benchutil.h` by include path rather than copying
it, the way the fixture font is already borrowed, and is labelled `perf` so
`ctest -LE perf` can leave it out. It runs at scale 1 in the ordinary suite --
1.2 seconds -- because a benchmark nothing ever builds is a benchmark that rots.

Both suites green: 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:25:22 -04:00
a1dcfcacf3 Land a character written past a short row's terminator
A grid row is a NUL-terminated string, and `putchar_at()` wrote the character,
advanced and terminated -- so `CHAR 1, 40, 1, "#"` on an otherwise empty row
stored the `#` at column 40 with `text[1][0]` still `'\0'`, and the render loop,
which stops at the terminator, drew nothing at all.

The silent nothing is the trap. The write succeeded, the cursor moved, the
stdout mirror showed the character, and only the window stayed blank -- so the
program looked right everywhere except where it mattered. The documented
truncation on the way *back* is fine and is unaffected.

`putchar_at()` now fills the gap with spaces before storing.

**Padded there rather than in `sink_moveto()`**, which TODO.md proposed: this way
`moveto` stays read-only -- the objection that entry raised against its own
suggestion -- and a row is only ever padded when a character actually arrives.

The pad fills from the terminator rather than replacing it. The buffer is not
cleared between rows, so replacing only the terminator would expose the tail of
whatever longer row used to be there: writing "ABCDEFGHIJ", truncating it to
"ABCX", then writing at column 7 must give "ABCX   Z" and not "ABCX FGZ".
tests/akgl_backends.c asserts all three cases, and the middle one keeps the
truncation pinned.

TODO.md section 6 item 32, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:13:42 -04:00
3415c9aec6 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
5c5bf63356 Draw into the whole window, not its top-left 320x200
The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.

akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.

SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.

SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.

The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:35:20 -04:00
9845e77a5c 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
fca9ad4a89 Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().

Two gaps were capabilities rather than inconveniences, and both are now real:

The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.

SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.

The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
4fc763efb3 Wire the sink and the three devices to libakgl
src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in
the akbasic_akgl target, which is the only thing here that links SDL.
-DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and
it now builds and passes.

The sink is what section 3 has been waiting on. Its character grid comes from
akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's
font.SizeUTF8("A") and the call that did not exist until 42b60f7. Wrapping is
done on the character grid rather than by handing SDL_ttf a wraplength, because
the cursor has to land somewhere definite: a program that PRINTs a long string
and then PRINTs again expects the second to start on the row after the first
ended, and only the code that placed the characters knows which row that is.

tests/akgl_backends.c draws into a 128x128 software renderer under the dummy
video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. It asserts the
seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the
right colour out.

Four things in libakgl had to be worked around to get here. All four are
commented at their site with "filed upstream" and recorded in TODO.md section 3:

- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
  be *installed*. It builds its own vendored copies only when it is top-level,
  and they are sitting right there in deps/libakgl/deps. Every lookup is guarded
  with if(NOT TARGET ...), so this adds those five subdirectories before
  add_subdirectory(deps/libakgl) -- the same trick and the same ordering
  requirement akerror::akerror and akstdlib::akstdlib already need.
- akgl/controller.h does not compile on its own: it declares handlers taking an
  akgl_Actor * and includes nothing that declares the type.
- There is no way to attach a 2D backend to a renderer you already have.
  akgl_render_init2d() installs the vtable but also creates its own window and
  writes the camera global, so it belongs to the akgl_game_init() path -- which
  is exactly the path an embedding host is not on. The test assigns the six
  pointers by hand.
- akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it
  reaches through renderer->draw_texture without checking it. Same class of
  defect 42b60f7's own commit added a draw test for.

The sink's readline reports end of input rather than reading: a drawn text layer
is not a source of lines, and INPUT through one wants a line editor built on the
keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT
already handles it. That editor is the next piece of work there.

70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite,
clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00