a01554d304f91111131fb8bcbf73ba185bb6cf27
60 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
9e43acc0b0
|
Give the characters breakout its bricks as collision geometry
`HITTEST`, `XBRICK` and `YBRICK` are gone -- about forty lines that divided pixels by cell sizes to recover a grid index, tested the ball's leading edge rather than its box, and could miss a brick clipped at the corner by seven pixels' worth of ball. In their place: sixty `SOLID` rectangles registered as the wall is built, `COLLISION 2, BRICKHIT`, and a fifteen-line handler. The handler reads better than what it replaces because it asks rather than derives. `RCOLLISION(1, 1)` is which brick -- the id is the array index plus one, so nothing is looked up. Fields 2, 3 and 4 are the way out and how far, so the ball is pushed exactly clear instead of being restored to a remembered `OX#`/`OY#`. Field 7 is which axis to reverse, which `TESTCELL` in the other game computes by hand from an overlap rectangle. `MOVEBAL` loses its two brick calls and its position backup. `KILLBR` retires the rectangle in the same breath as clearing the array element, so the next frame cannot hit a brick that is no longer drawn. **A latent defect in the target prescan had to be fixed first, and `RCOLLISION` is the first name in the language to reach it.** `src/renumber.c` walks a line character by character looking for `GOTO`, `GOSUB`, `COLLISION` and the rest, and checked only the character *after* a match -- so `RCOLLISION(1, 1)` found `COLLISION` at its second character, read the `(1,` that followed as a handler line number, and refused the whole program with "branch to line 1, which the program did not number", naming a line that contains no branch at all. It now requires a word boundary on both sides. The comment there was already right that the trailing check protects `GOTOX#`; nothing protected `XGOTO#`. `tests/unnumbered.c` covers all three shapes and TODO.md section 6 item 42 records it. The game runs ninety seconds headless with no error line and the attract mode scores 1320, so bricks are being found and broken through the new path. Chapter 17 is not updated yet -- that is the other half of section 6 item 39, and it is a bigger edit than this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL |
|||
|
fdb3421b2a
|
Let C call a BASIC function with values it already has
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m22s
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) Has been cancelled
`akbasic_runtime_call_function(obj, name, args, nargs, dest)`. The argument
binding is split out of the call-site handling, so
`akbasic_runtime_user_function()` becomes evaluate-the-leaves and then call the
same code -- and a verb that wants to hand a BASIC function four numbers has
somewhere to start, which it did not before. The only entry point took a parsed
AST call site, so calling a function required having been parsed as an
expression.
Behaviour-preserving: both suites pass unmodified. The one deliberate difference
is that the AST path now evaluates *all* the arguments before binding any of
them, where it used to interleave. That is the safer order and it is what
by-value passing means everywhere else -- interleaved, a later argument could see
an earlier one already in the callee's scope.
**Finding, filed as section 6 item 41: a multi-line `DEF` called outside a
running program does not run its body, and says nothing about it.**
DEF TRIPLE(N#)
T# = N# * 3
RETURN T#
PRINT TRIPLE(14)
At the REPL that prints "(UNDEFINED STRING REPRESENTATION FOR 0)". From a file
the same function answers 42. The multi-line body runs by spinning a line loop
guarded on `mode == AKBASIC_MODE_RUN`, which is true only of a program running
from a file; in REPL mode the loop is skipped and the result is the caller's
zeroed return slot. The single-expression form has no such loop, and every case
in tests/user_functions.c goes through run_program and is therefore in RUN mode,
which is most of why nobody had seen it.
**The obvious fix is wrong and I tried it.** Widening the guard to
`mode != AKBASIC_MODE_QUIT` makes the interpreter *hang* instead of answering
wrongly -- `akbasic_runtime_process_line_run()` does not advance a REPL-mode
runtime the way the loop assumes, so the environment never comes back. Trading a
silent wrong answer for a lock-up is worse, so it is reverted, the reasoning is
in a comment where the next person will try the same thing, and the fix is filed
rather than guessed at.
That bounds this entry point rather than blocking it: it reaches a multi-line
body while a program is running, which is exactly the case a verb calling a
callback is in. The new test asserts the single-expression form from C and says
in a comment why the multi-line one is not asserted, so the omission is a
statement rather than a gap.
What is still not done is the language half -- no verb takes a function, and
nothing resolves a bare word to a function rather than a label. Item 40 now says
so, and says it wants a verb that needs it rather than speculation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
|
|||
|
a9600c3fcc
|
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 |
|||
|
5709dc160c
|
Keep what a program draws, instead of making it a sprite
A drawing lasted exactly one frame. The verbs are immediate, they went to the back buffer, SDL double-buffers and the frontend never clears -- so the only way to keep a picture was to capture it with `SSHAPE` and install it as a sprite, which is what `examples/breakout/sprites/breakout.bas` spends two of its eight sprites doing. That was TODO.md section 9 item 9. The drawing verbs now render into a layer texture the frame composites under the text and the sprites. Draw once; it is there on every frame after. **Bracketed around the step phase, not around each verb.** One pair of `SDL_SetRenderTarget` calls a frame instead of one per `DRAW`, and it is also what makes `SSHAPE` read back what the program has just drawn rather than whatever the last frame left. **The layer is transparent where nothing was drawn.** It covers the whole window and composites underneath, so an opaque one would black out the frame the moment a program issued a single `DRAW`. And a fresh SDL target texture's contents are undefined, so it is cleared on creation -- skipping that puts uninitialised memory under the first frame's text and looks like a driver bug rather than a missing memset. **The line editor forced a wrinkle worth naming.** `akbasic_frontend_akgl_pump()` is called from two places with different answers to "is a render target current": the frame loop calls it between steps, and the sink's editor calls it from *inside* a step, borrowing a frame while it waits for a typed line. SDL refuses to present while a target is current, so the pump ends the layer, presents, and puts it back only if it was the one that ended it. `akgl_frontend` caught this -- it drives a REPL session, and it failed with "You can't present on a render target" the first time the brackets went in. This does not make a drawing *visible* on its own. The text layer still repaints every row it owns, opaque, every frame, and by default it owns the whole window; `WINDOW` shrinks it and that half was already fixed. The two together are what a picture needed, and the tests assert both -- a pixel still there a frame later with nothing redrawn, and a pixel below a shrunk text area surviving the text repaint. The second assertion wipes to a non-black colour first, because against black it could not tell a transparent layer from an opaque one. The tests found two of their own bugs on the way: `stop_runtime()` was not tearing the graphics backend down, so re-initialising it dropped a live texture on the floor; and a first draft called `begin()` before `start_runtime()`, which re-inits the backend, so the assertion read back off an orphaned render target and passed while proving nothing. Chapters 6 and 13 stop saying a drawing has to be redrawn every frame, because it does not. The batch-boundary tear stays documented -- it bites an `SSHAPE` capture, which matters much less now that capturing is not the only way to keep a picture. Both games still run clean. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL |
|||
|
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 |
|||
|
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 |
|||
|
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 |
|||
|
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 |
|||
|
89fca8007b
|
Assert the log line a failed report leaves behind
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m21s
akbasic CI Build / sanitizers (push) Failing after 4m32s
akbasic CI Build / coverage (push) Failing after 3m39s
akbasic CI Build / akgl_build (push) Failing after 21s
akbasic CI Build / mutation_test (push) Failing after 3m29s
A mutation run over src/runtime.c found that deleting the `LOG_ERROR_WITH_MESSAGE` in `report_and_reraise()` left the whole suite green. That line *is* the visible half of the "a failed report must not swallow the program's own error" fix -- without it the secondary failure goes nowhere and the only evidence is the original error still being the one raised. Nothing looked at it, so nothing noticed. `tests/trap_verbs.c` now redirects `akerr_log_method` into a buffer and drives the path with a sink that refuses every write, which is the only way left to make reporting fail now that the `TRAP` dispatch no longer allocates. It asserts the log line and that `errclass` still records the program's own error, and it kills the mutant. TODO.md gains a section for what the run found and what it did not: it was cut off at 551 of 997 mutants after ninety minutes, so 446 are unexamined and a full run belongs in the release workflow rather than here. The three regions this work touched were all covered, and the survivors in the skipped-block guard are named and assessed -- three of the four are equivalent for any program that can actually be written, and the one that is not would need a test with a loop on line 1. Also recorded: src/variable.c at 68.2% with every mutant on the inline-storage lines killed, and src/runtime_trap.c at 82.5% with no survivors in `set_error_variables()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
c8b917d205
|
Write down that the left operand decides integer or float arithmetic
No behaviour change, by decision. `A# * 0.45` is 0 and `0.45 * A#` is 1.35, because every operator branches on `self->valuetype` and converts the right operand to match. It is inherited from the Go reference, a C128 promotes to float instead, and this interpreter is at least consistent about it -- so a dialect saying the left operand wins is a defensible position, and changing it to promotion would alter the result of every mixed expression in every existing program. **The defect was that nobody said so.** Chapter 3's "Numbers" did not mention it, Chapter 13 did not list it among the differences, and nothing fails when a program gets it wrong -- it computes something else and carries on. The game in examples/ lost its per-level speed increase to `5.6 + LEVEL# * 0.45` evaluating to a flat 5.6, and bled velocity out of every bounce through `0 - BLVX%(B#)` quantising to whole pixels. Both read correctly. Neither produced a diagnostic. Now said in three places: a section in Chapter 3 with the demonstration and the two rules that keep a program out of it (put the float on the left, put the answer somewhere with a `%` on it), a row in Chapter 13 naming it as the difference from 7.0 most likely to turn a working listing into a quietly wrong one, and the reasoning on value.h where the operators are declared. tests/value_arithmetic.c pins it in both directions across multiply and subtract, with a comment saying it is the documented contract rather than an accident -- so promotion becomes a decision somebody takes deliberately rather than a change that could slip in under a passing suite. TODO.md section 9 item 4, struck as a documentation outcome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
d5f3c3ef5b
|
Accept GRAPHIC CLR and a negative DATA item
Two parse handlers refusing something the documentation promises. Landing together because they are the same defect twice -- a verb's own argument shape falling through to a general path that cannot see it -- in one file, found by one program, and verified in one pass. **`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so the generic arglist path scanned it as a command token and the expression parser answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()` already treats as "drop the saved shapes and go back to text", so both spellings are one statement and the exec handler is untouched. The documentation was right all along; nothing in it changes. **`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans the source text directly (src/data.c) and always returned the -5 intact. So the value was right and *reaching* the statement raised -- which, since section 4 settled that `DATA` at run time is a no-op, is what a program does with every `DATA` line it walks past. A table of coordinates or velocities is full of negative numbers, which is how a game found it. The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#` is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning what it says because other callers rely on it. tests/read_data.c covers both mechanisms -- reading a negative item and reaching the line after it -- with a mixed-sign table and a negative float, since the two were never the same code path. TODO.md section 9 items 7 and 8, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
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> |
|||
|
8594d8471d
|
Honour a subscript in SSHAPE and GSHAPE
`shape_variable()` took the identifier off the leaf and looked the variable up without ever evaluating the subscript, and both verbs then addressed element zero with a literal. So `SSHAPE SH$(2), ...` wrote the handle into `SH$(0)` and `GSHAPE SH$(2)` stamped whatever was in `SH$(0)`. Ordinary assignment and `PRINT` honour the subscript, which is what made this expensive: a program keeping several saved shapes in an array got every one of them resolving to the same element, silently, and the only symptom was that every stamp came out as the last shape captured. The Breakout in examples/ keeps its six brick stamps in six separate scalars for exactly this reason. `SPRSAV` was the counter-example and is the model -- it evaluates its argument and handles an array element correctly. The subscript resolution itself is now shared: `collect_subscripts()` comes out of src/environment.c as `akbasic_environment_collect_subscripts()`, so a verb taking a variable by name resolves a subscript the same way assignment does rather than each verb deciding for itself. tests/graphics_verbs.c covers TODO.md's reduction -- which used to print "[SHAPE:0] []" and now prints "[] [SHAPE:0]" -- and the case a program actually wants: two shapes captured into two elements, each stamped back through its own, asserted against the device log so a fix that merely made the strings look right would not pass. Chapter 18's trap 4 becomes history, and Chapter 6 says an array works. TODO.md section 9 item 6, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
694b446ce4
|
Let a program ask how big the text grid is
`RGR(1)` and `RGR(2)` gave the window in pixels and nothing gave columns, rows or the cell size -- so anything placing a character *and* a sprite at the same spot had to hardcode a number measured by hand against whatever font the host loaded. The Breakout in examples/ does exactly that, `CW# = 16`, and it is the one thing in that listing that breaks on a different font or window. **`RWINDOW` is BASIC 7.0's own answer and had never been implemented here.** `RWINDOW(0)` is the current text window's rows and `RWINDOW(1)` its columns. `RWINDOW(2)` reports a C128's 40 or 80 column screen mode, and this interpreter has neither -- refused by name, because answering 0 would be a plausible lie, which is worse than a refusal that says why. The cell size in pixels is `RGR(3)` and `RGR(4)`, beside the surface's own dimensions rather than on `RWINDOW`. Two reasons: a cell size is a fact about the surface, and `RWINDOW` reports the *window*, so dividing `RGR(1)` by a column count stops being right the moment a program calls `WINDOW`. Both read a new optional `grid` entry point on `akbasic_TextSink` -- columns, rows, cell width, cell height -- implemented by the akgl sink and forwarded by the tee, in the shape `moveto` and `window` already had. NULL everywhere else, so both verbs refuse by name against a sink with no grid. `akbasic_sink_init_ stdio()` clears it for the same reason it now clears the other two. Measured on the standalone build: `RGR(3)` answers 16 and `RWINDOW` answers 50 columns by 37 rows -- the three numbers the Breakout listing had written out as constants -- and `RWINDOW` follows a `WINDOW` call while `RGR(3)` does not. tests/console_verbs.c drives the answers through a stand-in sink with a grid, since the harness sink is stdio and has none; tests/graphics_verbs.c covers the new `RGR` fields, their refusal, and the moved range bound. The `c excerpt=` block in docs/10-embedding.md moves with the header, which is `docs_examples` doing its job. TODO.md section 6 item 31's second half, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
fd1f0d7c19
|
Forward WINDOW through the tee sink
`akbasic_sink_init_tee()` wired write, writeln, readline, clear and -- conditionally -- moveto, and never `window`. The standalone AKGL frontend runs the interpreter against a tee, so the fully implemented `sink_window()` underneath it was unreachable: `WINDOW 0, 0, 20, 4` answered "WINDOW needs a text device with a character grid, and this one has none" on the one build that has a character grid. It reads as an oversight rather than a decision, because `moveto` directly above it is forwarded with reasoning that applies unchanged. `tee_window()` is that function, modelled on `tee_moveto()`, offered only when a half can take it so the refusal still reads correctly through a stdio-only pair. **Writing the test found a second one.** Both optional entry points are assigned conditionally, and neither initializer cleared them first -- so a caller with a sink on the stack got whatever was in that memory, and `CHAR` and `WINDOW` decide whether they can act by testing those pointers for NULL. Both `akbasic_sink_init_tee()` and `akbasic_sink_init_stdio()` now clear them. An initializer that leaves a field alone is not an initializer. tests/sink_tee.c gains a stand-in sink with a grid -- the two stdio halves have neither entry point, which is exactly why they could not show that either is forwarded -- and asserts both directions, the arguments arriving intact, and that a grid-less pair still offers neither. Verified end to end: `WINDOW 0, 0, 20, 4` then `PRINT` succeeds under build-akgl/basic and still refuses by name under build/basic. What this does *not* fix is that a drawing still has to be re-issued every frame: the frontend never clears and SDL is double-buffered, so a drawing issued once appears in one buffer only. Shrinking the text area makes the rest of the window the program's; keeping something there is still the program's job. That half is documented rather than fixed, and is filed as TODO.md section 9 item 5. TODO.md section 6 item 31, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
5d33237eed
|
Release the scope a skipped BEGIN block's loop pushed
`akbasic_parse_for()` and `akbasic_parse_do()` create their environment while the line is *parsed*; whether to skip it is decided afterwards, when the line is evaluated. So a loop inside a block that was not taken pushed a scope, its body was skipped, and the `NEXT` or `LOOP` that would have popped it was skipped too. Nothing else ever would. At the top level that exhausted the pool after thirty-two skips. Inside a routine it was far more confusing: the orphan sat between the routine and its caller, so the `RETURN` after the block reported "RETURN outside the context of GOSUB" from a routine that plainly *was* entered by a `GOSUB` -- naming the one construct that was not at fault, which is why it cost an evening to find. The skip now releases what parsing pushed. **Narrower than it first looks.** Releasing on any skip breaks tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas: a zero-iteration `FOR` skips its body by the same mechanism, and there the orphan is load-bearing -- it absorbs the inner `NEXT` so the outer `NEXT` still finds its own `FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the release is conditional on the skip being a *block* skip, which is decidable because nothing inside a skipped block ever runs to arm a `NEXT` wait. Both halves are asserted side by side in tests/structure_verbs.c, the second one citing the golden case that caught it. The forty-skip case names its own step budget: a skipped line is not free, and forty passes over a five-line block cost about 2700 steps against the shared runner's 2000. Chapter 18's trap 3 becomes history rather than a warning, and the note in Step 5 that called `GOTO`-guarded loops "not a style choice" now says why the shape is kept anyway. TODO.md section 9 item 2, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
f5a35b4af6
|
Enter a TRAP handler when the variable table is full
Two separable faults, both on the path a program takes when it is already in trouble. `akbasic_trap_set_error_variables()` reached `ER#` and `EL#` through `akbasic_runtime_global()`, which *creates* a name the program never used -- and creating one takes a variable slot. So a program that had filled the 128-slot table could not have its handler entered at all, and because the failure happened inside the error path rather than raising, nothing was reported and the program carried on with the failing statement's effect quietly missing. A wrong answer delivered as a right one, which is worse than an abort. `akbasic_runtime_reserve_globals()` now creates both at runtime init, where there is always room, and `clear_variables()` puts them back after `CLR` and `NEW` empty the table. Second: `report_and_reraise()` used a plain `PASS` around the report, so a failure while reporting *replaced* the error the program had actually made -- "Maximum runtime variables reached" in place of the subscript that was out of range. The secondary failure is now logged and the original is re-raised, which is what a user needs to hear. **The reduction in TODO.md no longer reproduces, and not because of this.** The value-pool fix in the previous commit made a scalar free, so the pool can no longer be emptied by creating names. The defect was still live through the variable table: 124 names and a TRAP armed, and the handler was silently skipped. That is what the new test in tests/trap_verbs.c pins, together with the invariant -- both globals present before a program runs. Verified by reverting the fix against the new tests: both fail, and the second prints the log line the swallow used to eat, "could not report a BASIC error 515 (Out Of Bounds): Maximum runtime variables reached". TODO.md section 6 item 33, struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
05f241aca1
|
Stop a scalar created inside a scope costing value-pool slots
A scalar now lives in the variable record (`akbasic_Variable::inlinevalue`) rather than drawing from the value pool, so a `GOSUB` local, a `FOR` counter and a `DEF` parameter cost nothing at all. The pool is a bump allocator with no free, and its comment justified that with "nothing in BASIC destroys a variable". Scope exit does: it marks the variable slot unused, `new_variable()` memsets the slot it hands back -- clearing `values` -- and `variable_init()` therefore took *fresh* slots for a variable whose old ones were still counted. Every scope that created a local leaked, with no diagnostic until the pool ran dry on whichever line happened to be unlucky. Six thousand `GOSUB`s creating one local used to die on the 4091st at `LOC# = 1` with "Array of 1 elements does not fit in the 0 remaining value slots". They now run. A `DEF` called eight thousand times used to die between the four and five thousandth -- the leaking slot was the call scope's parameter, which is a scalar -- and both forms now run. A game creating one name per tick was dead in half a minute; the Breakout in examples/ was, after twenty-five seconds. **A `@` name is the one exclusion, and it is the whole of it.** A structure or a pointer to one keeps pool storage, because a pointer into a record outlives the scope that DIMmed it -- docs/16-structures.md says nothing is reclaimed and `prev_environment()` relies on it. The name suffix is the right test rather than `structtype`, which the DIM path sets *after* calling `variable_init()`. A local array therefore still leaks, deliberately, and is now the narrow rule the tutorial teaches. `SWAP` needed the other half: it copies whole variable records, so the `values` pointer that came over named the other variable's inline slot -- which by then held this variable's own old value -- and SWAP silently did nothing. Caught by tests/language/housekeeping/verbs.bas, which is the golden corpus earning its keep. tests/value_pool.c is the new coverage. It asserts the mechanism as well as the consequence: a later change that moved arrays inline too would pass every behavioural case and quietly break the pointer guarantee. The sharpest case takes the pool's whole 4096 slots in four arrays after two hundred scope entries, so one leaked slot has nowhere to go. Chapter 17 Step 3 taught "declare every name at the top" and no longer needs to. It now teaches what is still true -- a name first seen inside a subroutine dies at RETURN, so a routine cannot answer its caller through one -- and its demonstration is the array case, which still fails. TODO.md section 6 item 30 and section 9 item 1, both struck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
cb0e2d0800
|
Add two Breakout examples and the tutorials that build them
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m10s
akbasic CI Build / sanitizers (push) Failing after 4m5s
akbasic CI Build / coverage (push) Failing after 3m29s
akbasic CI Build / akgl_build (push) Failing after 21s
akbasic CI Build / mutation_test (push) Failing after 3m19s
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/` draws its wall in the text grid with two `DATA` sprites for the ball and paddle, and `sprites/` loads CC0 artwork and captures its whole screen with `SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely this interpreter's, which is what the chapters are for. `docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md` build each one a step at a time, and end in a checklist of the rules a real program runs into: create every name before the loop starts, write a text row whole, loop with `GOTO` rather than `DO`, put the float on the left. Every trap is a runnable block with its own output rather than a claim -- the value pool dying at four thousand names, the skipped `BEGIN` block that breaks its caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor. Five figures, generated from the listings beside them by `docs_screenshots`, and a `breakout_art` setup so the ones that load artwork load the example's own. The character game's wall cannot be photographed -- the screenshot host omits the text layer on purpose -- so it is shown as compared output instead. `docs/07-sound.md` never said `SOUND`'s frequency is a SID register value rather than hertz, which both games depend on. It says so now, with the conversion from `src/audio_tables.c:84`. `TODO.md` gains the thirteen defects the two games turned up -- §6 items 30 to 33 and all of §9 -- each with a reduction that fits on a screen, the file and line of the cause, and what a fix would touch. Verified: `docs_examples` passes in both build configurations, `docs_screenshots --check` re-renders all thirteen figures and byte-compares them, the full 109-test suite passes in both builds, every quoted fragment was checked to appear verbatim in the listing it came from, and every relative link and anchor resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
6273be580a
|
Refuse a branch to a line the program did not number
GOTO 100 in a script written without line numbers finds the hundredth line and branches there. Silent, plausible and wrong: the test for it loops forever printing the second line when the check is removed. akbasic_runtime_check_targets() is a fourth prescan beside the label, DATA and TYPE ones, run on every entry into MODE_RUN -- the earliest the check can be made and the only place all four ways a program arrives pass through. A target naming an empty line is still allowed, for the same reason RENUMBER leaves one alone, and a fully numbered program is unaffected, which is every program that existed before this. It shares RENUMBER's walk rather than repeating it. renumber.c grows an akbasic_TargetWalk -- a self pointer and a visit function -- and rewrite_line() takes one. RENUMBER's visitor substitutes the number a line moved to; the check's substitutes the number unchanged and raises. One walk, so the two cannot disagree about what a branch target is. The check points environment->lineno at the line being walked so the "? N :" prefix names the offending line. The other three prescans do not and report whichever line the loader stopped on; TODO.md section 5 item 64 records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
8bc253ebbf
|
Give a loaded line a number when it arrives without one
A script written against LABEL and GOTO NAME never names a line number, so the numbers it had to carry were decoration. akbasic_runtime_load(), RUNSTREAM and DLOAD now file an unnumbered line one slot after the last one filed; a numbered line is filed under its number and moves the cursor, so the two mix. akbasic_runtime_file_line() is the one implementation of that rule, so the three paths cannot drift. The prompt is untouched. A line typed without a number is still direct mode and still runs now -- that is the only thing separating program text from a statement at a REPL, and it is why this is a loading feature. What this replaces was silent data loss: an unnumbered line was filed under the cursor unchanged, on top of the line before it. A blank line therefore erased whatever preceded it, and RUNSTREAM did not skip blank lines the way the other two paths did. That moves one golden file, and the reference had the same defect. language/arithmetic/integer.bas has four PRINT statements, an expectation with three values, and a trailing blank line that erased 40 PRINT 4 - 2 before the program ran. The expectation is now 4 4 2 2. tests/reference/README.md records the divergence and TODO.md section 5 item 63 says why. akbasic_SourceLine grows a `numbered` flag so an assigned number can be told from a written one. RENUMBER sets it on every line it touches; NEW, DELETE and DLOAD clear it. hadlinenumber moves to akbasic_scanner_scan(), so it always describes the line just scanned rather than only the REPL's. Two lines carrying the same written number still keep the last, as they always have. That is a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
d2ae0d75de
|
File the scanned line in RUNSTREAM, not the raw one
process_line_runstream() chose between the stripped line and the raw buffer on obj->mode == AKBASIC_MODE_REPL, but nothing reaches that function except the RUNSTREAM arm of step(). The stripped branch was dead, so every program the driver ran from a file was stored with its own line number inside source[]: LIST printed "10 10 PRINT", and DSAVE and HELP would have echoed it the same way. No .bas in either corpus calls LIST, and runtime_verbs.c's load() helper -- which claims to load "the way RUNSTREAM would" -- already stored the scanned line, so it encoded the correct contract and could not catch the wrong one. The new test drives the real sink path instead. Keep the step-over-a-leading-number allowance in scan_line_labels() and skip_lineno(): no path inside the library stores a raw line now, but akbasic_runtime_store_line() is public and a host may hand it one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
812d846e47
|
Take libakgl 0.7.0, and refuse an over-long type or field name
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m6s
akbasic CI Build / sanitizers (push) Failing after 3m59s
akbasic CI Build / coverage (push) Failing after 3m25s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Failing after 3m15s
0.6.0 and 0.7.0 break nothing here: 0.6.0 is three arcade-physics fixes and a physics.max_timestep property this port does not use, and 0.7.0 reports failures libakstdlib's wrappers were already catching and takes libakerror 2.0.1 so it can drop the exit-status trap its own suites needed. That is the same defect include/akbasic/error.h guards for this band, now fixed at the source rather than worked around in two places. The floor moves to 0.7.0 anyway. The soname carries MAJOR.MINOR while the major is 0, so deciding for ourselves which of libakgl's minor releases were really compatible is exactly the judgement it exists to take away. 0.7.0 is largely about a defect class worth checking for here rather than assuming past: ten unterminated strncpy calls into fixed-width name fields. Ours are clean -- every one is bounded to size - 1 and every buffer is either explicitly terminated afterwards or memset first, checked site by site. But two of them, both written last week in src/structtype.c, truncated an over-long name silently, and truncation is an error everywhere else in this interpreter. It matters more for a name than for a value: two long type names trimmed to the same 31 characters collide, and the second is then reported as redeclaring a type the program never wrote. Both are refused now, showing the name as written. The check was unreachable as first drafted, because the prescan read words into a buffer the size of the limit and so trimmed them before anything could look -- the scratch is twice the limit now, which is what makes the two cases distinguishable at all. tests/struct_types.c pins that it stays reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
c1d06ee4b8
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
|
00daa17a47
|
Give each DEF call its own environment, so recursion returns
The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:
DEF DBL(N#) = N# * 2
PRINT DBL(10) + DBL(1) was 4, should be 22
The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.
DEF FACT(N#)
IF N# <= 1 THEN RETURN 1
RETURN N# * FACT(N# - 1)
PRINT FACT(5) never returned
The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.
A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.
Recursion depth answers to AKBASIC_MAX_ENVIRONMENTS like every other nesting, so
too deep is "Environment pool exhausted" -- a diagnosis where there was none.
akbasic_FunctionDef.environment goes with it, as dead state.
One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.
Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
|
631c70ce7d
|
Share a host program's own C structures with a script
A BASIC TYPE and a host C struct are the same thing seen from two sides, so both
go in one type table and everything the language already does with a structure
works across the boundary with no second set of rules. The host describes its
struct once as a table of field descriptors and binds an instance:
akbasic_host_bind(&SCRIPT, "FOE@", "ENEMY", &GOBLIN);
after which `FOE@.HP# = FOE@.HP# - 10` decrements GOBLIN.hp in place, with no
marshalling step the host has to remember to run.
The sharing is done with shadow slots: a binding takes a run from the same value
pool a DIMmed record uses, a field read refreshes its slot from host memory
first, and a write converts back and stores. So the script always sees current
values and its writes always land, while the rest of the interpreter goes on
seeing one storage model instead of two.
AKBASIC_HOST_FIELD takes the offset and the width from the same member, which is
the only reason it is a macro: writing offsetof and sizeof out by hand is two
chances to name the wrong member and no way to notice. A field name's suffix
must agree with the C type it describes, refused at registration -- a host
writing "HP%" over an int32_t has said two different things about one field and
the script would believe the suffix.
Conversion refuses rather than truncates. 200 into an int8_t, 70000 into an
int16_t, -1 into a uint8_t and thirteen characters into a char[8] are each an
error naming the field, because a silent wrap is found three frames later in
code that did nothing wrong. Each width is tested separately, since a range
check is exactly the thing that is right for int32_t and wrong for int8_t when
only one of them is covered.
The language's own distinction turns out to be the one a host needs, so there is
one API rather than two: assignment copies and gives a script a private
snapshot, POINT shares and lets it change the game, and which one happened is
visible in the listing.
Two things the work required. The prescan runs again on every RUN and used to
wipe the whole type table, unregistering the host's types the first time a
script ran; it now keeps what the host registered and drops only what the script
declared. And akbasic_runtime_start() did not rewind, which cost nothing while a
host started a script once and cost everything to a host running one per enemy
-- the second start began past the end and silently did nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
|
2a3f68d2c4
|
Add strict pointers: AS PTR TO, POINT ... AT, and the -> operator
A pointer is a distinct declared kind rather than a mode a structure can be in, which is what "strict" means here: `.` requires a structure on its left and `->` requires a pointer, neither stands in for the other, and both refusals name the operator the program should have used. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data. Assignment still copies. POINT is the only way to share, so a program that never writes it can never be surprised by aliasing -- and `P@ = A@` is refused with a message saying to POINT it instead, rather than quietly becoming the one assignment in the language that does not copy. PTR TO is also the only way a TYPE may refer to itself, since by value it would have no finite size. That is what makes a linked list possible, and tests/language/structures/pointers.bas builds one, walks it and renders it. Rendering follows pointers, so it needs a depth bound where copying does not: copy stops at a pointer by construction, but two nodes pointing at each other is easy to write and PRINT would not come back. Four levels, chosen so the bound bites before the 256-byte render buffer does -- otherwise a cycle would stop because it ran out of room rather than because it was told to. Three things the work turned up, all now pinned by tests: A freshly DIMmed record printed `(UNDEFINED STRING REPRESENTATION FOR 0)` for every field. Slots now take the type their field declared, so it reads as zeros. Adding POINT as a verb makes POINT unusable as a type name, and the parser reported that as "Expected expression or literal" pointing at the line rather than the problem. The prescan now refuses a reserved word as a type name and says so. Field names follow the same reserved-word rule as variable names, enforced by the loader's own scan -- `TO@` is a bad field name for exactly the reason `TO#` is a bad variable name. Recorded rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
6a7c8cd920
|
Add records: TYPE, DIM ... AS, field access, copy on assign
BASIC 7.0 has no records at all, so none of this is a port. The `@` suffix was not invented either: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until now. Declaring the type is what buys the storage model. A TYPE states its fields, so an instance has a known slot count and is laid out exactly as an array is -- one contiguous run from the same value pool DIM already draws from, with field access as offset arithmetic. No new pool holds data; the only new table holds descriptors. A nested value flattens into its container's run, which is why LINE with two POINTs and a string is five slots rather than three. Each field takes its type from its own suffix, the same rule every other name here follows, so a field list needs no type column. An `@` field is the exception and has to name its type, because three primitive types fit in three suffix characters and N declared types do not fit in one. The declaration is prescanned before the program runs, like labels and DATA and for the same reason: it has to be in effect wherever control goes. Three passes, each for a case the one before cannot do -- names first so a field can refer to a type declared later, then field lists, then sizes by repeated resolution. What never resolves is a cycle of by-value containment, so "a TYPE cannot contain itself by value" is a diagnosis rather than an assumption, and the message says to use PTR TO instead. Assignment copies. That interception is the whole feature and it cannot live in akbasic_value_clone(), which copies one slot -- and one slot holds a *reference* to an instance rather than the instance, so going through it would alias. A structure is intercepted before that path and its slots are copied one at a time, walking the descriptor rather than memcpy-ing the run, because a pointer field must copy its reference where a value field must copy its slots. Two smaller things the work required. All three prescans now sit inside one ATTEMPT: a malformed declaration is the program's mistake, and it was printing a stack trace and taking the driver with it, which is the boundary goal 3 exists to draw. And a fresh variable's structtype is -1 rather than the 0 a memset leaves, because 0 is a valid type index and every new variable was claiming to be the first type declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
8117fb564d
|
Refuse an operand the numeric path cannot read
math_minus, math_multiply and math_divide were `if ( INTEGER ) ... else <treat
as float>`, and that else was a catch-all rather than a float branch: it read
floatval from whatever it was handed. A truth value keeps its payload in
boolvalue and leaves floatval zero, so a truth value on the left computed into a
field nothing reads and kept its BOOLEAN type --
(A# == 1) - 1 printed true, should be -2
(A# == 1) * 3 printed true, should be -3
(A# == 1) + 1 correctly refused
wrong in value and in type, and silent. math_plus escaped only because it
enumerates its cases and ends in an error.
The three now share one require_numeric() guard, so a type added later is
refused by all of them at once instead of quietly taking the float branch in
each. The two operands have different rules and that asymmetry is the point: the
left one picks the branch and must be a number, while the right is read through
rval_as_int(), which handles -1/0 deliberately. `5 - (A# == 1)` is 6, and that
is the same property that lets AND and OR double as logical operators, so
refusing a truth value on the right would have broken every condition in the
language.
Found by asking what a structure operand would do to this path, which is where
AKBASIC_TYPE_STRUCT is about to arrive. The structures work did not create the
defect; it made an already-reachable one worth chasing.
Two stale comments went with it. src/value.c's header and a duplicate block
above rval_as_int both cited "TODO.md section 12", which does not exist -- the
defect list is section 6 -- and the duplicate described the summing behaviour
item 5 had already removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
|
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> |
|||
|
8077806598
|
Take libakerror 2.0.1, and guard the exit status it fixes
2.0.0 makes the error pool and the status registry thread safe, and it is an ABI break carrying the soname to libakerror.so.2. The break is a quiet one: __akerr_last_ignored became thread-local and akerr_next_error() now returns a context that already holds a reference, so objects compiled against a 1.x header and linked against 2.x count every reference twice and never give a slot back. Nothing about that fails to link, which is exactly what a guard is for -- include/akbasic/error.h feature-tests AKERR_THREAD_SAFE instead of AKERR_FIRST_CONSUMER_STATUS, which 2.0.0 also still defines and which therefore no longer distinguishes anything. 2.0.1 is the release this band needed most. The default unhandled-error handler ended in exit(errctx->status), and a process exit status is one byte: AKBASIC_ERR_BASE is 512, and 512 truncates to 0, so an unhandled AKBASIC_ERR_SYNTAX reported success to anything watching $?. Every other code in the band came out as some unrelated error's number. akerr_exit() substitutes 125 for anything a byte cannot carry, and a probe raising AKBASIC_ERR_DEVICE through FINISH_NORETURN now exits 125 rather than 7. It was latent here rather than live -- src/main.c handles the context and returns EXIT_FAILURE, and every test with a top-level ATTEMPT carries a HANDLE_DEFAULT -- but "no caller relies on it today" is not a property a header can keep true. tests/version_check.c asserts the mapping and fails if AKBASIC_ERR_BASE ever stops truncating to zero, because that is the day this stops being about our base. Chapter 10 gains a threading section: libakerror is safe from any thread now, and this interpreter is not and has no lock anywhere in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
16b38c1138
|
Generate the documentation's figures from the listings they illustrate
Chapters 6 and 8 described what a verb draws in prose. Eight figures now show it, and each one is produced by running the BASIC listing printed immediately above it -- so a picture cannot drift away from the code beside it, which is the way a screenshot goes wrong and the way nothing notices. tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy video driver, software renderer, run to completion, read the target back, write a PNG. It draws no text layer on purpose, so a READY in the corner is not noise in a figure about BOX and no font has to be resolved. tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the point being made is a 320x200 listing filling a larger window, which cannot be made on a 320x200 surface. Two gates, answering different questions. docs_examples fails a tagged block with no image, in both configurations, so a figure cannot be added and forgotten. docs_screenshots -- a CTest, AKGL build only -- re-renders every figure and compares byte for byte, so a listing edited without regenerating fails. Only the second catches a stale picture. The PNGs are checked in because a reader on the forge has no build tree, and docs/images/README.md says loudly that they are generated. Regenerating is never part of a build: the target is run deliberately, so a make cannot put eight binary diffs in front of whoever ran it. Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed in bold that BOX fills on a negative angle while its own paragraph said the fill was filed rather than implemented. BOX cannot fill, and filled_rect is reached by no verb as a result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
48f650c3af
|
Stop an armed TRAP from swallowing parse errors whole
A program with TRAP set and a line that would not parse printed nothing at all and exited zero. No error line, because akbasic_runtime_error() intercepts for the trap and deliberately prints nothing; and no handler, because the parse branch of process_line_run() then set run_finished_mode regardless -- QUIT for a file -- so the next line boundary the handler would have been entered at never came. Arming an error handler made errors disappear. The guard is to set the finished mode only when the error was actually reported. The runtime path was always right: report_and_reraise() never touched the mode. The same branch also never recorded the status, so the handler that now runs was handed ER# 0. It sets lasterrorstatus from the context the way report_and_reraise() does, and a trapped SCALE with no arguments reports 512. Found while writing the error-code appendix -- documenting what ER# can hold means provoking each code, and this is what happened on the way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
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> |
|||
|
23ccb66f69
|
Document the interpreter's architecture as chapter fourteen
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m50s
akbasic CI Build / coverage (push) Failing after 3m22s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Successful in 12m19s
Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
342e4c07da
|
Execute every documented example as a test
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m52s
akbasic CI Build / coverage (push) Failing after 3m24s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Has been cancelled
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
6f49f6a7f2
|
Stop DLOAD eating a line, and a prompt error killing the driver
Two defects that the same mechanism produced, both found by making the examples in docs/ actually run. DLOAD scans the file it reads through the runtime's own scanner, so by the time it returns, the tokens of the line that invoked it are gone and hadlinenumber and environment->lineno describe the last line of the *file*. The REPL carried on parsing what it believed was still its own buffer, concluded the leftovers were program text because hadlinenumber was now true, and filed the DLOAD command itself under that line number -- silently replacing the last line of every program loaded from a prompt. It now abandons the rest of the line, which is the only thing that can sensibly follow replacing the whole program. The second is the same class of oversight one layer up. process_line_run() has always swallowed the error context after interpret() puts the BASIC-level line on the sink, with a comment saying that is what goal 3 requires. The direct-mode branch of process_line_repl() used a bare PASS instead, so `VERIFY` against a file that did not match -- an ordinary user answer, not a fault -- came back as a stack trace and terminated the driver. An embedding host would have gone with it. Both tests fail when their fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
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> |
|||
|
df50201121
|
Own every pixel each frame, and bring the cursor back as a blinking block
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 19s
akbasic CI Build / sanitizers (push) Failing after 13s
akbasic CI Build / coverage (push) Failing after 16s
akbasic CI Build / akgl_build (push) Failing after 18s
akbasic CI Build / mutation_test (push) Failing after 17s
The blinking backspace was not the backspace. The sink skipped rows it believed were already clear, tracked in a drawn[] array, which assumes a frame inherits the frame before it. SDL_RenderPresent swaps buffers, so a frame inherits the one two back: a row erased once is clean in one buffer and dirty in the other, and presenting alternates between them. The first character of a backspaced wrap flickered forever. It reproduces on X11 and never under the dummy driver or a software renderer, which is why the suite stayed green through two rounds of fixing it. The sink now repaints every row it owns every frame -- a frame either owns every pixel it presents or inherits pixels it cannot reason about. Confirmed on real hardware with the reported input: the wrapped tail's row reads zero across five successive captures. The cursor returns as a blinking block, half a second per cycle, off SDL's clock. It sits in the cell after the text rather than under it, which is what made the underscore unreadable, and it wraps to the next row when a line exactly fills one -- where the next character actually lands. The new regression test paints stale pixels by hand, which is what a swapped-in buffer hands back, so the case is covered without real hardware. Every new assertion was checked by reverting the fix and watching it fail. Recorded honestly in TODO.md section 5: the same buffer swap means the graphics verbs were never reliable across frames either. A one-shot DRAW lands in one buffer and the next present shows the other. Making that work needs a persistent surface, which is its own commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
acd20eed9a
|
Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:
- a backspaced character stayed on screen, including across a line wrap
- a scroll left the old rows behind, "papered over with garbage"
- the editing cursor smeared an underscore along every column it passed
through, which read as an underline under the text being typed
render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.
echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.
The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.
Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
|
cfdde907df
|
Type at the window with a real keyboard: the akgl_typing test
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 18s
akbasic CI Build / sanitizers (push) Failing after 13s
akbasic CI Build / coverage (push) Failing after 16s
akbasic CI Build / akgl_build (push) Failing after 17s
akbasic CI Build / mutation_test (push) Failing after 17s
xdotool closes the gap that shipped the missing SDL_StartTextInput(). Every other keyboard test synthesises SDL events, which covers the code downstream of SDL and cannot cover the code upstream of it -- so the suite stayed green while the real keyboard was dead. akgl_typing starts the driver under a pty, waits for the window with xdotool search --sync, gives it focus, and types a program containing a string literal and a lower-case one, polling the mirrored stdout for what they print. Verified by reverting both halves of the text-input fix and watching it fail with the reported symptom: READY, and nothing after it. Skips rather than fails without a display, xdotool, script(1) or a window manager -- none of those means the answer is no. It steals keyboard focus for about fifteen seconds; AKBASIC_SKIP_INTERACTIVE=1 skips it deliberately. xdotool and script(1) are documented as optional test dependencies, alongside what gcovr and python3 already bought. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
024a33dbba
|
Start SDL text input: the AKGL build's keyboard was dead
SDL3 has text input off by default and per-window, so without SDL_StartTextInput() it emits no SDL_EVENT_TEXT_INPUT at all and every keystroke reaches libakgl's ring with an empty text field. The editor had just been changed to prefer that composed text over the keycode, so it read every key as not-a-character: no echo in the window, and nothing on stdout either, because RUN could never be typed. One cause, both symptoms. The editor now falls back to the keycode when there is no composed text, so a host that forgets to start text input gets a worse keyboard rather than none. The suite missed this because every keyboard test pushes the text-input event into SDL's queue by hand -- which is what a real keyboard produces, but only once text input has been started. Synthesising the end of a chain cannot test the beginning of it. The new test asserts SDL_TextInputActive() directly, and both halves were checked by reverting each and watching it fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
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> |
|||
|
583c0abbd2
|
Retire the byte-for-byte fidelity constraint, and fix what it was blocking
The Go reference is deprecated and will not be updated, so the two projects are no longer required to match. Recorded as TODO.md section 0.1, first thing in the file, because it silently reverses the premise several later sections were written on -- an agent reading section 6 without it will park work that is no longer blocked. Section 6 item 16 was the item waiting on exactly this and is now fixed: the keyword tables are searched on the base name with any type suffix stripped, so PRINT$, LEN# and GOTO% are refused as variable names and the reference's own diagnostic stops being dead code. It cost the one golden case predicted, and the cost was nothing -- renaming a variable in strreverse.bas left its expected output byte-for-byte unchanged. AKBASIC_KNOWN_FAILING_TESTS is empty as a result and known_reference_defects.c is gone. Section 6 items 1-9 and 11 are reopened as an ordinary defect list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
eb5374d212
|
Check the reference corpus in, so the build stops needing the Go submodule
All 41 .bas/.txt pairs copied byte for byte from basicinterpreter@d76162c into tests/reference/, plus the Commodore font into assets/fonts/ -- the two things that tied the build to deps/basicinterpret. Both were verified cmp-identical to the submodule copies. This reverses a decision that was deliberate and correct at the time: the corpus was driven in place because copying a submodule's corpus guarantees drift. Overruled on purpose -- the Go dependency is being deprecated, and a build that cannot run its acceptance suite without cloning the implementation it replaced is not finished. tests/reference/README.md records what the drift now costs and that those expectations are never edited. Checked rather than assumed: both configurations configure, build and pass from scratch with deps/basicinterpret moved out of the tree entirely. The submodule is kept as the behavioural spec, which is a real use. The font came with an open licence question; assets/fonts/PROVENANCE.md states it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
bfac13ff87
|
Implement the housekeeping verbs, and stop the REPL spinning after an error
NEW, CLR, CONT, SWAP, TRON, TROFF and HELP. None is in the Go reference, so what each means here is recorded beside it. Testing CONT turned up a hang that predates this: the runtime's error class is deliberately sticky, and with run_finished_mode REPL every later step re-entered REPL and printed READY -- overwriting the QUIT that end of input had just set. An interactive session that hit one runtime error printed READY forever instead of exiting. RESTORE and RENUMBER are deferred with reasons; RESTORE turns out to need a DATA pointer this port does not have, which is a defect in its own right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
f4007d80d4
|
Give argument lists their own link, not each argument's .right
An identifier's subscript list, a unary's operand and a binary's right-hand side all lived in the same field the argument chain used, so ABS(-9), MOD(A#, B# + 12) and any array reference in a parameter list were counted as extra arguments and refused. Subscript lists move to .expr and arguments now chain through a dedicated .next. This is the third time the same collision was fixed; the first two moved it along rather than removing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
5b7b7d2ed9
|
Add akbasic_runtime_global: a host variable lands in the script's root scope
A host creating a variable while a script was suspended got it in whatever scope was active -- usually a FOR or GOSUB body -- and it died when the body popped, silently. Reaching for the root by hand returned NULL without raising, because environment_get only auto-creates in the active environment. Both are still true of environment_get, which is correct for what the interpreter uses it for. The README and the example now point somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
a31058cf37
|
Consume the COLON token: a line can hold several statements
The token has existed since the scanner was written and nothing read it, so 10 PRINT A$ : REM ... was a parse error. Leading separators are consumed before each statement and an empty statement yields a NULL leaf rather than an error, so a trailing colon and a run of them are both legal. BASIC 7.0 scopes everything after THEN to the condition, which the reference had no opinion about because it never got here. The rule is not "skip when false": the rest of the line belongs to whichever arm was written last. A whole FOR/NEXT on one line still does not loop -- block skipping works by source line. Recorded in TODO.md as what group A has to fix first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
|
0f3d8a0ac6
|
Close the value.c mutation survivors, and correct one that cannot be
Two new tests in value_arithmetic.c, each verified by hand-applying the mutant rather than by assuming. The obvious maximum-length-string test does not work: math_plus clones self into the scratch first, so joining a full string to an empty one leaves the byte a short copy missed already correct. The operands have to sum to the limit without either being the answer. One of the five listed survivors is equivalent and cannot be killed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |