Commit Graph

72 Commits

Author SHA1 Message Date
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>
2026-07-31 11:46:10 -04:00
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>
2026-07-31 11:38:45 -04:00
1eb8f9ffaa Fix five defects the port uncovered in the reference parser and scanner
Chained subtraction and exponentiation fold left instead of abandoning the
rest of the line -- `1 - 2 - 3` gave -1 and dropped the `- 3`. A unary leaf's
operand moves to .left, so a negative literal is one argument again and a
second argument no longer overwrites it. An operator in a line's final column
survives. Hex literals reach the parser whole. A leading zero is padding, not
a radix.

Item 16 stays pinned: the fix works and costs an upstream golden case, which
is a decision rather than a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:35:14 -04:00
f24201fdef Port the standalone SDL frontend, and give the sink a line editor
An AKGL build of `basic` was a terminal program with unused SDL linked into
it. It now opens the reference's 800x600 window, draws BASIC output in the
Commodore font, pumps events, lets you type at it, and still mirrors every
byte to stdout.

The stdout mirror is a composing sink rather than a second write inside the
interpreter, and lives in the core library where it needs no SDL. The line
editor waits for a typed line by borrowing one frame at a time from the host,
so nothing blocks and nothing owns an event loop it should not.

The whole golden corpus now runs through the SDL binary as well as the stdio
one, byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:21:39 -04:00
7897a49fb5 Record the unfinished standalone AKGL frontend
AKBASIC_WITH_AKGL currently builds and links four tested adaptors, but basic never selects them. It remains the stdio driver, creates no SDL resources, attaches no devices and pumps no events.

Narrow the completed claim to the core port and adaptor layer. Add the missing host responsibilities, affected files and acceptance criteria, then put the standalone frontend and its line editor at the top of the remaining-work queue.

Co-Authored-By: Codex GPT-5 <noreply@openai.com>
2026-07-31 10:57:02 -04:00
ecdc6b60ef Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 2m52s
akbasic CI Build / sanitizers (push) Successful in 3m9s
akbasic CI Build / coverage (push) Failing after 3m12s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Successful in 7m27s
That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family
could not report a conversion failure at all -- atoi("not a number") returned
success with 0, turning four diagnosable errors into wrong answers. Its own note
said what to do when that changed: "When it grows it, delete src/convert.c and
switch the call sites over." 0.2.0 grew it, so it is gone.

Six call sites go straight to the library now: both literal constructors in
src/grammar.c, the line number in src/scanner.c, FunctionVAL in
src/runtime_functions.c, INPUT in src/runtime_commands.c, and GSHAPE's handle in
src/runtime_graphics.c. aksl_strtoll(str, NULL, base, &dest) rather than
aksl_atoll wherever a base is involved, because the ato* forms are base 10 and
grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what
makes trailing junk an error rather than a stopping point.

The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message
with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes
message text part of the acceptance contract, so that was checked against the
corpus before touching anything: no golden file contained a conversion message,
which is also why section 1.9 had been asking for one. Two exist now, so the next
change to that text has to move a golden file, and one of them pins the
reference's octal-literal defect (section 6 item 10) while it is still
deliberately reproduced.

tests/convert.c became tests/numeric_contract.c rather than being deleted with
the code. The assertions did not stop being worth making when the wrapper went
away -- they became assertions about a contract this port depends on and no
longer owns, and a regression in it would make four things quietly return zero.

Repoints the CI mutation job, which was bounded to src/convert.c and
src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65.
src/audio_tables.c was measured as a replacement second file and scored 64.7%:
that is a real gap rather than a reason to skip it, since almost every survivor
is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio
state is actually zeroed -- the same gap this job's history records closing for
symtab. Recorded in TODO.md so the file can earn its place back.

One thing worth knowing before reading any score here, and now written down: the
harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of
other values produces no mutants and a high score over one says nothing about
whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR
entries against the datasheet anyway, because a hand-edit typo is the real
failure mode there -- but that could not and did not move the mutation number,
and claiming otherwise would be the kind of thing this file exists to stop.

72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and
97.8% function, clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
0df0a95600 Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.

The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.

The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.

src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.

libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.

70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
f7511678b8 Cover -DAKBASIC_WITH_AKGL=ON in CI
A fifth push job, akgl_build: the text sink and the graphics, audio and input
backends, plus the akgl_backends suite that drives them against a real software
renderer under the dummy video and audio drivers and reads the pixels back.

It stays a separate job rather than a flag on cmake_build because
AKBASIC_WITH_AKGL is off by default and that default is the claim being made:
the interpreter and its whole 70-case suite build and pass on a machine with no
SDL. Keeping the two jobs apart proves that on every push instead of asserting
it.

Deferring this assumed it needed submodules: recursive and a long build. Both
guesses were wrong, and measurement is the only reason we know:

- Recursive is not needed and costs 461 MB. It descends into SDL_image/external,
  SDL_mixer/external and SDL_ttf/external -- aom, dav1d, libjxl, libtiff,
  mpg123, opus, flac and a dozen more -- and not one of them is used. Every one
  configures as "Could NOT find". Six submodules initialised non-recursively
  take about 25 seconds instead.
- The build is roughly a minute of CPU across 330 object files, because SDL3
  compiles out almost every backend that is not wanted here.

The one real system dependency is libfreetype-dev and libharfbuzz-dev. SDL_ttf
reports "Using system freetype library" and links libfreetype.so.6; without them
it would reach for deps/SDL_ttf/external/freetype, which the checkout
deliberately does not clone. Two apt packages against two more submodules.

Every step was run verbatim from a clean clone before being written down --
checkout, submodule init, configure, build and test -- rather than inferred from
the working tree. 71/71.

Also corrects three counts elsewhere in the file that had gone stale: the suite
is 70 cases rather than 61, line coverage is 93.5% rather than 92.3%, and branch
coverage reads 18% rather than 17%. Those numbers are assertions about the gate,
so a wrong one is worse than none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:06:24 -04:00
ff7bec9f55 Record the real coverage numbers, and how they were nearly misreported
93.5% of lines and 97.8% of functions, up from 92.3% and 96.9% across roughly 800
new lines. The verb groups added for goal 2 carry the new surface: the two table
files and runtime_input.c at 100%, runtime_graphics.c and runtime_audio.c at 98%,
play.c at 87%.

The first reading of that number was wrong and the reason is worth keeping. A
stale build-cov/ left in the source directory by an earlier session was silently
folded in by `gcovr --root .`, which reported the *previous* run's 92.3% for a
tree that had grown by 800 lines -- a number that looked plausible precisely
because it had not moved. That is libakgl defect #13 happening here rather than
there, and build*/ being gitignored is what makes it invisible. Section 8 now
says to check `find . -name '*.gcda'` before believing a coverage figure that
looks suspiciously unchanged.

Also documents the akgl build and test commands in the README, which had none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:51:27 -04:00
da50da70b5 Bump libakgl to pick up the six defects this port filed
Records the filing in TODO.md section 7 alongside the FILTER gap, and names the
four workarounds this repository carries against them -- the embedded-dependency
CMake trick, the akgl/actor.h include ahead of akgl/controller.h, and the
hand-populated render vtable with its NULL-deref hazard. Each is commented at its
site with the words "filed upstream" so it can be found and deleted when the
fixes land.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
83185bafa8 Read the keyboard: implement GET, GETKEY and SCNCLR
The poll_key half of group E, against the akbasic_InputBackend record. The
libakgl implementation behind it is akgl_controller_poll_key(), which drains a
ring the library fills from SDL events the host pumps -- so the interpreter can
answer "is there a key waiting" without owning an event loop, which is what goal
3 requires.

GET and GETKEY differ in one way and it is the interesting one. GET takes
whatever is there including nothing, and an empty buffer is success with the
empty string rather than an error -- that is what happens on most iterations of
every GET loop ever written, and upstream is explicit that its poll reports it
the same way. GETKEY waits, and since the library may not block, waiting is
spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step()
declines to advance until a key arrives. Every step still returns and a bounded
run() still comes back, so a host keeps its frame rate; the program simply does
not move past the GETKEY.

The PLAY queue is serviced before that check on purpose -- music should keep
playing while a program waits for a keypress. Withdrawing the input device while
a GETKEY is holding releases it rather than wedging the script on a device that
no longer exists.

Both verbs accept an integer variable as well as a string one and give it the raw
key code, which is what a program testing for cursor or function keys needs. No
key is code zero, matching what a C128 reports. A float variable is refused: it
is neither a character nor a code.

SCNCLR goes through the text sink rather than a device, because the sink is where
PRINT already goes and is the only thing that knows what a screen means for this
host. The stdio sink treats it as a no-op; clearing a pipe means nothing.

One thing worth knowing before writing any bounded-run test, and now commented in
tests/input_verbs.c: source lines are stored indexed by line number and the
cursor starts at zero, so a step is spent on each empty slot along the way. A
program at line 10 needs eleven steps before it has run anything.

70/70 ctest, clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:35:05 -04:00
e24926d429 Sound: implement the BASIC 7.0 sound verbs and the PLAY parser
SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record,
plus FILTER, which is in the table only so that it can be refused with a reason.

The PLAY note-string parser and TEMPO are here rather than in libakgl because
that is where its audio commit says they belong: a tone generator synthesises
pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is
a language question.

PLAY does not block. On a C128 it holds the program until the last note ends,
which section 1.6 forbids outright, so it parses the string into a fixed queue
and returns; akbasic_runtime_step() releases one note at a time against whatever
time the host last passed to akbasic_runtime_settime(). The driver now steps one
at a time with the clock refreshed in between rather than making a single
unbounded run() call -- a tune whose notes all measured themselves against a
frozen zero would rush out at once. That loop is its own function because CATCH
expands to a break and PASS expands to a return of the context, and main()
returns an int; wrapping the loop is what the protocol prescribes for that shape.

src/audio_tables.c holds the three conversions, laid out as tables because each
is somewhere a wrong constant produces a plausible wrong pitch rather than an
error anybody would notice. Two are transcriptions -- the SID frequency formula
and its non-linear ADSR rate tables, where decay is exactly three times attack.
The third is not: BASIC 7.0 never published what a whole note lasts at a given
TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter
note at 120 bpm, and it is labelled as a choice where it is made.

SOUND's frequency sweep is refused rather than faked, and filed upstream as
akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(),
which ties audible pitch to how often the host calls us -- a tune that changes
key with the frame rate. FILTER is refused for the reason upstream already gave:
there is no filter stage and SDL3 has no primitive to build one from.

The PLAY parser is tested through akbasic_play_parse() directly rather than
through a program, because running one also runs the queue service -- and with
the clock at zero every duration has already expired, so the queue empties before
an assertion can look at it. Draining is correct behaviour and is tested on its
own; the parse tests ask a different question.

68/68 ctest, clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
9d99d0a67e Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.

The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:

- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
  deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
  so the primitive could serve only the fully-defaulted call, and a shape that
  changed character depending on whether the radii happened to be equal would be
  worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
  because a value's string is a fixed 256 bytes and a region is a device surface.
  GSHAPE refuses a string without that prefix instead of parsing whatever digits
  it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
  would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
  anything here -- mode 0 is text -- while still refusing an out-of-range mode,
  since that is a typo worth catching.

PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.

COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.

Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.

65/65 ctest, clean under -Wall -Wextra, doxygen clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
5077efc3a7 Re-sequence the language queue now that libakgl closed the gaps
The bump in 7f16337 updated sections 3, 7 and 8 but left the section 4 work
queue still marking groups G and I "file it", which is no longer true.

Groups G and I are unblocked outright. Group E splits: GET, GETKEY and SCNCLR
need nothing further, while WINDOW, KEY, SLEEP, WAIT and TI still want the sink
or a clock. LOCATE was listed in both E and G; it belongs to G alone, because in
BASIC 7.0 it moves the graphics pixel cursor DRAW starts from and the text
cursor verb is CHAR, already in group D.

One gap is genuinely outstanding and upstream named it in the same commit that
added the audio API: FILTER has no SDL3 primitive behind it. It is recorded in
section 7 with the behaviour to apply until an akgl_audio_filter() exists --
refuse at execution rather than silently ignore, since a program that asks for a
low-pass and gets an unfiltered square wave has been lied to.

Also records why the libakgl requirement is pinned by commit rather than by
version: 42b60f7 added 22 public symbols across four headers and left both
VERSION 0.1.0 and the libakgl.so.0.1 soname alone, so AKGL_VERSION_AT_LEAST
cannot tell the two trees apart and a binary built against the new headers
resolves against an old .so without complaint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:01:16 -04:00
7f16337a16 Bump libakgl to 42b60f7: every gap this port filed is now closed
All four capability gaps filed against libakgl have landed upstream, so nothing
in this repository is waiting on that library any more.

  text measurement   -> akgl_text_measure, akgl_text_measure_wrapped
  immediate drawing  -> akgl_draw_point/_line/_rect/_filled_rect/_circle/
                        _flood_fill/_copy_region/_paste_region
  audio              -> akgl_audio_init/_tone/_envelope/_waveform/_volume/
                        _stop/_voice_active/_mix
  console input      -> akgl_controller_poll_key, akgl_controller_flush_keys

The signatures came back close to what was filed, and two details are worth
recording for whoever writes src/sink_akgl.c next. The draw calls take an
akgl_RenderBackend * as their first argument rather than reaching for a global
renderer, which fits goal 3's rule that the interpreter draws through whatever
renderer the host already initialized. And the audio API is a synthesised-voice
one -- a tone on a numbered voice plus a separate ADSR envelope -- which is the
shape PLAY and ENVELOPE actually need, rather than the sample playback
SDL3_mixer would have offered.

Checked rather than assumed: libakgl's status band is still 256-260, so the
coordinated range map in CLAUDE.md is unaffected and akbasic's 512-767 does not
move.

Documentation that described these as open is corrected in the same commit --
section 7's gap list, section 3's "known gap" note, the priority list, the
dependency table, and the README's unimplemented-verbs section all said blocked
and no longer are. Section 3 also gains the thing that replaced the gap: the
character grid comes from akgl_text_measure(font, "A", &w, &h), the direct
equivalent of the font.SizeUTF8("A") at basicruntime.go:96, and
akgl_text_measure_wrapped takes the same wraplength argument
akgl_text_rendertextat does so the measurement and the draw cannot disagree
about where a line breaks.

One caveat recorded rather than papered over: -DAKBASIC_WITH_AKGL=ON has never
been configured in this repository, because until now there was nothing to build
against. The akbasic_akgl target is unproven and needs libakgl's own submodules
present. Expect to fix something there on the first attempt.

Default build unaffected: ctest 61/61, doxygen exits 0, no warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:56:02 -04:00
506af093a1 Add mutation testing, and split the release gates into their own workflow
Ports libakstdlib's mutation harness (the newest of the three) to scripts/
mutation_test.py, adds the namespaced `mutation` CMake target the sibling
libraries have, and splits CI in two.

.gitea/workflows/ci.yaml keeps the push path: suite, ASan+UBSan, coverage, and a
mutation run bounded to src/convert.c and src/symtab.c -- about four minutes,
scoring 77.8% against a gate of 65.

.gitea/workflows/release.yaml is new and manual (workflow_dispatch). It carries
the doxygen gate, moved out of ci.yaml, and a whole-tree mutation run: 3675
mutants and hours of runner time, which is a release cost rather than a
per-commit one. Both artifacts a release wants -- api-documentation and
mutation-report -- come out of it. Two optional inputs narrow the run or change
the threshold; they arrive through the environment rather than being
interpolated into the shell, because ${{ }} substitution happens before the
shell sees the line.

The harness paid for itself immediately, which is the point of it. Three real
gaps, each checked to be a genuine bug rather than an equivalent mutant:

  - errno was never asserted clear before a strtoll. Confirmed with a standalone
    probe that strtoll leaves a stale errno untouched on success, so without the
    `errno = 0` a valid conversion raises ERANGE.
  - Nothing exercised a maximum-length symbol-table key, so every MAX_KEY - 1
    off-by-one in a strncpy and its NUL terminator survived. The same hole exists
    for strings in src/value.c and is filed.
  - Nothing asserted a freshly initialised table was actually zeroed.

Closing the first two took the measured score from 73.1% to 77.8%.

I set the push-path threshold to 75 first, on an estimate. Measuring gave 73.1%
and the job would have failed on its first run -- the earlier per-file figure was
too high because the captured output had been truncated to its last lines and I
counted fewer survivors than there were. It is 65 now, and the gate was run as
written and confirmed to exit 0.

src/value.c is the file most worth mutating and is deliberately off the push
path: 368 mutants at ~11s each is about 70 minutes, because almost everything
links against it. A partial run over it found the same maximum-length-string
hole plus two genuinely equivalent mutants that only exist because of reference
defect section 6 item 5 -- adding both of the right operand's numeric fields
works only while the unused one is zero, so + and - are interchangeable there.
Recorded in TODO.md.

ctest 61/61; doxygen exits 0; both workflows' steps were executed locally, both
input paths included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:46:53 -04:00
2ddee571a1 Add a Gitea workflow
Fills the gap TODO.md recorded last commit: the sibling libraries all carry
.gitea/workflows/ci.yaml and this one did not, so every gate was run by hand.
Four jobs, in the shape libakerror and libakstdlib use, down to the JUnit
reporting and the annotate_only note about Gitea 404ing on the Checks API.

cmake_build configures, builds and runs the 61-case suite with JUnit output.
docs runs `doxygen Doxyfile` -- a real gate, since WARN_AS_ERROR is set -- and
uploads build/docs/html as the api-documentation artifact. sanitizers runs the
whole suite under ASan and UBSan, which libakstdlib's TODO.md calls its own
highest-value missing item and which matters here because this library is all
fixed pools and manual buffer arithmetic. coverage gates at 90% of lines against
an actual 92.3% and uploads the html report.

Two things worth knowing, both checked rather than assumed.

The checkout is submodules: true, not recursive. The build needs libakerror,
libakstdlib and basicinterpret; it does not need libakgl, which is guarded
behind AKBASIC_WITH_AKGL and defaults OFF, and recursing would clone SDL,
SDL_image, SDL_mixer, SDL_ttf and jansson for a target that is never configured.
The nested submodules are uninitialized in this working tree today and the build
is green, which is the proof. The docs job takes no submodules at all -- verified
by running doxygen against a tree with deps/ absent.

There is no branch-coverage gate. Branch coverage reads 17.3% and is not a
meaningful number here, for the reason libakgl's and libakstdlib's TODO.md both
record: the akerror control-flow macros expand into large branch trees at every
call site, most unreachable in normal operation. Gating on it would mean writing
tests for libakerror's macros.

All four jobs were executed locally end to end before committing, not just
eyeballed: 61/61 plain, 61/61 under ASan+UBSan, doxygen exit 0 producing 441
files, and gcovr exit 0 at the 90 gate. The gate was also confirmed to bite by
raising it to 99.

Not added: a mutation-testing job, which all three siblings have. There is no
scripts/mutation_test.py in this repository to run, and porting libakerror's is
its own piece of work -- filed in TODO.md, where it matters more than usual
because the akerror macros expand at their call sites and coverage cannot see
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:09:38 -04:00
134ade9392 Document the public API in libakgl's Doxygen style
Adds a Doxyfile in the shape libakgl uses -- fifteen deliberate lines, including
WARN_IF_UNDOCUMENTED and WARN_AS_ERROR=FAIL_ON_WARNINGS -- and fills in the 70
public declarations that had no doc block, bringing include/akbasic to 114 of
114.

libakgl is the model rather than libakstdlib. Measured before starting: libakgl
documents 122 of 122 public declarations and libakstdlib 3 of 25, and
libakstdlib's Doxyfile is the unedited doxygen default, PROJECT_NAME = "My
Project" and an empty INPUT. So the house standard is libakgl's, down to the
boilerplate phrasing for the recurring parameters -- "Object to initialize,
inspect, or modify", "Output destination populated by the function", "`NULL` on
success, otherwise an error context owned by the caller".

Worth knowing what the gate actually gates. EXTRACT_ALL=YES suppresses
doxygen's undocumented-entity warnings, so the rule it enforces is that a
*partial* block is an error: document one @param and you must document them all.
Verified by deleting a @param and confirming a non-zero exit, then restoring it.
Full coverage is therefore a convention this commit adopts rather than something
the tool made me do.

Where a contract is non-obvious the block says so rather than restating the
signature: math_plus explains why it alone mutates its left operand, new_unary
notes that hanging the operand on .right is what makes the parser miscount a
negative literal argument, leaf_to_string warns that an assignment renders with
an empty operator, and stop_waiting records that a verb nobody is waiting for is
tolerated. Each cross-references its TODO.md section 6 item.

Also records in TODO.md that this repository has no CI, which libakgl and
libakstdlib both have -- so ctest, the sanitizer build, coverage and this new
doxygen gate are all run by hand today.

ctest 61/61; doxygen Doxyfile exits 0; no warnings under -Wall -Wextra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:00:16 -04:00
e51b9a1694 Document host/script variable exchange, and file the scope hazard it exposes
The host can expose variables to a BASIC program, in both directions and for
every type, with no marshalling layer -- akbasic_environment_get() finds or
creates by name, the type comes from the suffix as it does for BASIC code, and
host and script share the same akbasic_Value. Nothing new was needed to make
that work.

Testing it before writing it up turned up a hazard worth stating plainly, so the
README documents the pattern and section 6 item 17 records the gap.

Seeding before akbasic_runtime_start() and reading after the script stops is
safe, and so is updating an existing global at any time, because the parent
chain is searched. Creating one mid-run is not, in two ways, and both are
silent. A script suspended part-way through a bounded run() is usually inside a
FOR or GOSUB scope, so a variable created through obj->environment lands in that
scope and dies when it pops -- the script reads it correctly inside the loop and
gets 0 immediately after. And reaching for the root explicitly does not help:
akbasic_environment_get only auto-creates when the environment it is given is
the active one, so with a child active it returns NULL through dest without
raising, and an unchecked host dereferences it.

This one is ours rather than inherited. It falls out of the environment pool
meeting the bounded run(), a combination the reference never had because its
run() never returned.

examples/hostvars.c demonstrates both hazards rather than describing them, and
prints what it observes, so the day this is fixed that output changes and the
example needs revisiting. Like examples/embed.c it is built and run by every
build. Both README snippets were extracted and compiled as a check.

Not fixed here: akbasic_runtime_global() would close it in about fifteen lines,
but what it should do when the script is suspended inside a user function's
scope is a design question worth settling deliberately rather than discovering.
Filed with the proposed signature and the tests it wants.

ctest 61/61; ASan+UBSan 61/61; no warnings under -Wall -Wextra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:50:55 -04:00
cde6fa8f59 Port the README from the Go version and document embedding
Carries the reference README across, adjusted where C changes the answer: cmake
instead of make, the ak* libraries instead of the go-sdl2 bindings, and the
limits table now includes the three ceilings the Go version did not need because
it called make().

The new "Embedding the interpreter" section is the point of the rewrite, so it
states the four rules the library holds to -- nothing terminates the process,
nothing calls malloc, no file-scope mutable state, the host owns the loop -- and
each was checked against the tree rather than asserted.

Adds akbasic_runtime_load(). Writing the section turned up a real gap: a host
usually already holds its script as a string and wants the sink reserved for
output, and the only path that existed was AKBASIC_MODE_RUNSTREAM reading the
program through the sink's readline, which forces a game to point its output
device at its source text. The alternative was reaching into the header's
"internal API" block for store_line. Neither is something to put in a README.

Adds examples/embed.c, which is the code the README quotes -- a custom sink, a
bounded per-frame run, and the PASS-not-CATCH rule for a loop inside an ATTEMPT.
It is built by every build and registered as a CTest case, so a signature change
breaks the build instead of rotting the document. The README's own snippet is
compiled separately as a check; both were run before committing.

The "What Isn't Implemented / Isn't Working" section leads with the eleven
inherited defects rather than burying them, because five of them were found by
this port and a reader deserves to know that 1 - 2 - 3 computes 1 - 2 before
they hit it. Corrected two claims while verifying: the runtime is 10.1MB rather
than the ~8MB first written, and its largest single cost is the environment pool
at 4.1MB, not the source table.

ctest 60/60; ASan+UBSan 60/60; no warnings under -Wall -Wextra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:42:08 -04:00
f8c15c2f2c Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.

The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.

Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.

Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.

src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.

Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.

Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.

The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.

ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.

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