2 Commits

Author SHA1 Message Date
01adf80751 Rework the megademo to fit the 80-column source line limit
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m29s
akbasic CI Build / coverage (push) Failing after 3m58s
akbasic CI Build / sanitizers (push) Failing after 4m36s
akbasic CI Build / mutation_test (push) Failing after 3m52s
akbasic CI Build / akgl_build (push) Failing after 7m29s
AKBASIC_MAX_LINE_LENGTH's cut from 256 to 80 left seventeen lines of
examples/megademo unloadable: the sixteen IM$() picture strings (up to
252 characters) and TUNEA/TUNEB's four-bar PLAY strings (174 and 175).
The real ceiling is 78 characters, not 80 -- stdio_readline() refuses a
read that fills the 80-byte buffer without a newline, so content plus
its terminator must fit in 79.

The picture: vaporwave.py's PAYLOAD drops from 240 to 64, so every
emitted IM$(NN) = "..." line fits under the ceiling. chop() no longer
slices blind; it walks the stream a record at a time -- two characters
for a run, three for an R row record -- and never cuts inside one,
because the decoder reads a record's tail with MID on the string it is
walking and a record straddling two IM$ entries decodes as garbage.
The old blind slice at 240 only happened to be safe. verify() now
simulates the CHOPPED strings with the cursor threaded across the
boundaries exactly the way DRAWSTREAM executes them, so a bad cut is
an assertion failure instead of a corrupted screen, and emit_block()
asserts every emitted line fits. The picture is 56 strings where it
was 16; the decoder needed no changes at all, since it already carries
X#/Y# from one IM$ entry to the next.

The music: TUNEA and TUNEB each become four PLAY statements, one bar
apiece. play.c keeps voice, envelope, level and duration state on the
runtime across statements and every PLAY appends to the same queue, so
four bars queue exactly as one long string did. Each bar restates the
V1T3U9S prefix so a bar dropped by QFULL cannot leave the next batch
playing on the drum kit's envelope.

Everything still clears the shrunken pools with room to spare: 1625
source lines of 2048, ~704 array slots of 2048, identifiers within the
24-character symtab key. Verified end to end against this branch's
build: the demo loads, the offscreen host renders every scene, and the
scene-5 still is pixel-identical to vaporwave.py's own preview. The
test suite fails the same seventeen cases with and without this
commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACffnV6F7sxQuG3Y8a1L3s
2026-08-03 22:57:57 -04:00
17af2d406c Cut akbasic_Runtime's static footprint from 10.75 MiB to 2.40 MiB
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m29s
akbasic CI Build / coverage (push) Failing after 3m40s
akbasic CI Build / sanitizers (push) Failing after 4m37s
akbasic CI Build / mutation_test (push) Failing after 3m35s
akbasic CI Build / akgl_build (push) Failing after 7m20s
Nothing in this interpreter mallocs; every pool is a fixed array sized by an
AKBASIC_MAX_* constant, so sizeof(akbasic_Runtime) is a compile-time number
and most of it was headroom nobody was using. Measured concurrent-use
high-water marks off examples/breakout and examples/megademo -- the two most
demanding programs this interpreter runs -- against each pool's ceiling:

  AKBASIC_MAX_ENVIRONMENTS   32 -> 12    (measured peak concurrency: 6-7)
  AKBASIC_MAX_FUNCTIONS      64 -> 8     (measured: 0, neither program uses DEF FN)
  AKBASIC_MAX_ARRAY_VALUES 4096 -> 2048  (measured peak: 1618 slots)
  AKBASIC_MAX_SOURCE_LINES 9999 -> 2048  (measured: ~1270-1496 non-blank lines)
  AKBASIC_SYMTAB_MAX_SLOTS  256 -> 172   (no caller ever requests more than 128)
  AKBASIC_SYMTAB_MAX_KEY     64 -> 24    (longest identifier measured: 11 chars)
  AKBASIC_MAX_LINE_LENGTH   256 -> 80    (Commodore BASIC's own line limit)

AKBASIC_MAX_VARIABLES (128) is untouched on purpose: breakout alone reaches
121 of 128 concurrent named variables, so it has the least slack of any pool
measured and is not a shrink candidate.

akbasic_Variable.name shrinks from AKBASIC_MAX_STRING_LENGTH (256) to
AKBASIC_SYMTAB_MAX_KEY: every variable name is registered with
akbasic_symtab_set() right after this field is populated
(akbasic_environment_create(), src/environment.c), and that call already
refuses anything AKBASIC_SYMTAB_MAX_KEY characters or longer. The wider field
was headroom nothing could ever put a byte into.

Two defects surfaced while testing the line-length drop against the golden
corpus, both fixed here because the 80-byte ceiling makes them routine rather
than theoretical:

- sourcepath (runtime.h) was borrowing AKBASIC_MAX_LINE_LENGTH by accident.
  It holds a directory, not a line of BASIC, and this checkout's own test
  paths are 81+ characters deep -- every golden test failed to load until
  this split into its own AKBASIC_MAX_SOURCE_PATH_LENGTH, backed by PATH_MAX
  the way libakerror already sizes its own path buffers.

- src/sink_stdio.c's stdio_readline() called aksl_fgets() but never checked
  its own documented contract: a full buffer with no trailing newline means
  the line was longer than the buffer, and the unread remainder is still in
  the stream. Unchecked, the next readline() picks that remainder up as its
  own statement -- a real line silently becomes two wrong ones instead of a
  clean AKBASIC_ERR_BOUNDS refusal. At 256 bytes this was theoretical; at 80
  it is not, so it now refuses loudly.

tests/value_pool.c's test_pool_is_untouched_by_scopes() was pinned to the old
4x1024=4096 pool math (four max-size arrays proving nothing leaked); rewritten
to 2x1024=2048 for the same proof against the new AKBASIC_MAX_ARRAY_VALUES.

Known consequence, tracked in andrew/akbasic#32 rather than worked around
here: two files in the protected tests/reference/ corpus
(language/functions/mod.bas, language/flowcontrol/nestedforloopwaitingfor
command.bas) have 82-character lines and cannot be shortened -- MAINTENANCE.md
and CMakeLists.txt:585 are explicit that tests/reference/ is never edited to
suit this interpreter. Twelve tests/language/ cases and one docs/18 line are
in the same position but are this project's own content. Shipping 80 anyway,
with the fallout tracked rather than hidden, was an explicit call on this PR
rather than something decided here.

Verified: cmake --build build-akgl && ctest --test-dir build-akgl, 97/112 (15
known failures, all AKBASIC_MAX_LINE_LENGTH-related, filed as #32).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 21:44:16 -04:00
105 changed files with 390 additions and 134 deletions

View File

@@ -17,7 +17,7 @@ jobs:
# Not recursive, deliberately. The top-level build needs
# deps/libakerror and deps/libakstdlib, via add_subdirectory, and
# nothing else: the golden corpus and the Commodore font now live in
# this repository (tests/language/ and assets/fonts/), so
# this repository (tests/reference/ and assets/fonts/), so
# deps/basicinterpret is no longer a build dependency at all.
# It does *not* need deps/libakgl, which is guarded behind
# AKBASIC_WITH_AKGL and defaults OFF -- and recursing into it would
@@ -62,9 +62,11 @@ jobs:
run: |
cmake -S . -B build
cmake --build build --parallel 2
# The suite is 112 cases: 65 language files with sibling expectations,
# 43 unit tests, 3 embedding examples, and docs_examples.
# Some unit tests assert the *correct* contract for known defects (TODO.md
# The suite is 78 cases: 41 golden files byte-compared against the Go
# reference's own corpus (checked in at tests/reference/, see its README),
# 9 local golden cases for verbs the reference never implemented, 25 unit
# tests, 2 embedding examples, and 1 known-failing test that asserts the
# *correct* contract for defects carried over from the reference (TODO.md
# section 6). A green run therefore does not mean defect-free -- see
# AKBASIC_KNOWN_FAILING_TESTS.
#

View File

@@ -57,9 +57,9 @@ repeating where you will see them:
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script.
- **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces
and there is no repo-wide formatter; style conversions get their own commit.
- **Keep `tests/language/` editable.** Its `.bas` programs and sibling `.txt` expectations
are changed together when behavior changes. Record deliberate language decisions in
`TODO.md` or `docs/13-differences.md`.
- **Do not edit `tests/reference/`.** Those expectations came from the Go implementation and
are never edited to suit this interpreter. A deliberate divergence goes in
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`.
- **Open an issue for outstanding work; do not add it to `TODO.md`.**
<https://source.starfort.tech/andrew/akbasic/issues>, or `tea issues create --repo
andrew/akbasic`. Name the file and line, the functional consequence, and what closing it would

View File

@@ -448,7 +448,7 @@ if(AKBASIC_WITH_AKGL)
# against.
#
# **A byte comparison of a rendered PNG is a deliberate bet**, the same bet
# the language corpus makes about golden output: that the dummy video
# tests/reference/ already makes about golden output: that the dummy video
# driver and the software renderer are reproducible. They are, run to run and
# build to build. What is untested is an SDL upgrade that shifts one pixel of
# a diagonal, and the answer to that is to regenerate the figures in the same
@@ -531,20 +531,63 @@ if(AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
)
endif()
# The editable language corpus. One CTest case per .bas so a failure names the
# file. Each program is paired with a sibling .txt expectation; see
# tests/language/README.md for the editing rule.
# The reference's own corpus, byte-compared against the sibling .txt. One CTest
# case per .bas so a failure names the file.
#
# The corpus includes programs carried over from the deprecated Go implementation
# and cases written for this interpreter. Their provenance is useful when
# investigating a regression, but it does not make any case immutable.
# It used to be driven in place out of deps/basicinterpret, on the reasoning that
# copying a submodule's corpus guarantees drift. That reasoning was sound and it
# has been overruled deliberately: the Go dependency is being deprecated, and a
# build that cannot run its acceptance suite without cloning the implementation
# it replaced is not finished. The copy is byte-identical to
# basicinterpreter@d76162c and tests/reference/README.md records
# where it came from and what the drift now costs.
file(GLOB_RECURSE AKBASIC_GOLDEN_CASES
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests/reference"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/*.bas"
)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
_add_test(
NAME golden_${_name}
COMMAND ${CMAKE_COMMAND}
-DBASIC=$<TARGET_FILE:basic>
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/${_case}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
)
endforeach()
if(AKBASIC_GOLDEN_CASES)
set(AKBASIC_GOLDEN_NAMES)
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
string(REGEX REPLACE "^tests/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name})
endforeach()
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
# An AKGL build of `basic` opens a window, and forty-one of them is not what
# anybody running the suite wanted. The dummy driver produces the same stdout,
# which is the only thing a golden case compares.
if(AKBASIC_WITH_AKGL)
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
endif()
endif()
# The local golden corpus, for verbs the reference never implemented.
#
# Programs carried over from the deprecated Go implementation and cases written
# for this interpreter use the same editable `.bas`/`.txt` contract. Registered
# under local_ so every failure identifies the program that produced it.
# Still separate now that the reference's corpus lives in this repository too,
# and the reason changed rather than went away: tests/reference/ is a *record* of
# what the Go implementation did and nothing in it should ever be edited to suit
# this one, while tests/language/ is ours to change. Registered under local_ so a
# failure says at a glance which of the two it came from -- and so a diff that
# touches tests/reference/ stands out as the thing it is.
#
# Note what this can and cannot cover. The graphics and sound verbs draw and play
# rather than print, so what a golden file sees of them is their refusals and
# rather than print, so what a golden file sees of them is their *refusals* and
# whatever a program can PRINT about the state they changed. The behaviour that
# reaches a device is asserted against tests/mockdevice.h instead.
file(GLOB_RECURSE AKBASIC_LOCAL_CASES

View File

@@ -88,7 +88,7 @@ source stays readable as documentation of what the original did. Neither is bind
**It is not a build or test dependency.** Both configurations have been configured, built and
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
`tests/language/` and its Commodore font at `assets/fonts/`.
`tests/reference/` and its Commodore font at `assets/fonts/`.
```sh norun
cd deps/basicinterpret
@@ -351,10 +351,15 @@ name. That is not cosmetic: `add_executable` creates a dependency's targets even
### The golden corpora
`tests/language/` is the editable language corpus. It includes cases carried over from the
deprecated Go implementation as well as cases written for this interpreter. Every `.bas` file
has a sibling `.txt` expectation, and a new language feature needs that pair as well as unit
tests. Change both deliberately in the same commit; provenance does not make a case immutable.
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared.
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`, not into the
expectation file. `tests/reference/README.md`
says the same thing at more length.
`tests/language/` is ours and may be changed freely. A new language feature needs a
`.bas`/`.txt` pair there as well as unit tests.
### Mutation-check a fix before you believe it

View File

@@ -10,7 +10,7 @@ implementation that started from the Java Lox instructions in
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
project is deprecated. It is vendored here as the behavioural spec to read when a question about
semantics comes up, and its acceptance corpus is checked in at
[`tests/language/`](tests/language/README.md) and runs on every build — so nothing about
[`tests/reference/`](tests/reference/README.md) and runs on every build — so nothing about
building or testing this project needs it.
## Quickstart
@@ -24,7 +24,7 @@ ctest --test-dir build --output-on-failure
```sh norun
./build/basic # the REPL
./build/basic tests/language/functions.bas # run a program
./build/basic tests/reference/language/functions.bas # run a program
```
```basic
@@ -129,7 +129,7 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
| [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice |
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
| [`tests/language/README.md`](tests/language/README.md) | The editable language corpus and the rule for changing it |
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.

37
TODO.md
View File

@@ -38,7 +38,7 @@ What it changes:
- **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs
a golden file, which is a cost rather than a veto.
- **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits.
- **`tests/language/` is the editable language corpus rather than a protected specification.** Diverging from
- **`tests/reference/` becomes a regression suite rather than a specification.** Diverging from
it is allowed and must be deliberate and recorded — see its README.
What it does **not** change:
@@ -306,13 +306,13 @@ to `Println`, which adds another.
**This used to be a hard contract and is now a default.** The Go implementation is deprecated
and will not be updated, so the two projects are no longer required to match — see §0.1. What
survives is the practical half: these strings and this newline behaviour are what every
expectation in `tests/language/` was written against, so changing one means changing the paired
expectation in `tests/reference/` was written against, so changing one means changing golden
files, and that is worth doing on purpose rather than by accident. A message that reads
awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit,
and add a line to §5.
Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f`
(Go's `%f` and C's `%f` both give six decimals — `tests/language/arithmetic/float.txt`
(Go's `%f` and C's `%f` both give six decimals — `tests/reference/language/arithmetic/float.txt`
confirms). No reason to change it, which is different from not being allowed to.
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
@@ -375,7 +375,7 @@ Phases 0 through 6 of the original plan are done. The interpreter builds clean u
It *did* reproduce the reference byte for byte, and that claim is retired rather than broken:
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
`tests/language/README.md`). Everything else still matches, which is worth knowing but is no
`tests/reference/README.md`). Everything else still matches, which is worth knowing but is no
longer a gate.
```sh
@@ -408,16 +408,17 @@ and the only path that existed — `AKBASIC_MODE_RUNSTREAM` reading through the
is the code the README quotes, built by every build and registered as a CTest case so a
signature change breaks the build rather than rotting the document.
**The acceptance suite is the editable language corpus, checked in at `tests/language/`.** All
65 `.bas` files are registered as individual CTest cases and compared against their `.txt`
**The acceptance suite is the reference's own corpus, checked in at `tests/reference/`.** All
41 `.bas` files are registered as individual CTest cases and byte-compared against their `.txt`
— including the trailing double newline on an error line (§1.8).
It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
acceptance suite without cloning the implementation it replaced is not finished. The copy is
originally byte-identical to `basicinterpreter@d76162c`, and `tests/language/README.md` records the
provenance and the rule that programs and expectations are edited together deliberately.
byte-identical to `basicinterpreter@d76162c`, and `tests/reference/README.md` records the
provenance, the cost of the drift nobody is watching for now, and the rule that those
expectations are never edited to suit this interpreter.
**Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked
rather than assumed — both configurations were configured, built and run from scratch with the
@@ -1025,7 +1026,7 @@ deviations from the reference's *program*: `main.go` and the SDL half of
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
did exactly that; see `tests/language/examples/strreverse.bas`.
did exactly that; see `tests/reference/README.md`.
### Deviations in statement separation
@@ -1390,10 +1391,10 @@ deviations from the reference's *program*: `main.go` and the SDL half of
**This one moved a golden file.** A line with no number used to be filed under the loader's
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
silently lost the first, and a *blank* line erased whatever preceded it. The reference does
the same, and `tests/language/arithmetic/integer.bas` is the proof: four `PRINT`
the same, and `tests/reference/language/arithmetic/integer.bas` is the proof: 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` and
`tests/language/README.md` records it.
`tests/reference/README.md` records it.
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
@@ -1585,9 +1586,9 @@ be reproduced before it can be fixed.
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
to `SOURCE$` in `tests/language/examples/strreverse.bas`, the expectation is byte-for-byte unchanged — the program
to `SOURCE$` in `tests/reference/`, the expectation is byte-for-byte unchanged — the program
still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in
`tests/language/README.md` records the corpus editing rule.
`tests/reference/README.md`'s divergence table, which this is the first entry in.
This item sat parked because the corpus was the acceptance contract and lived in a submodule
this repository could not edit. Both premises are gone: the corpus is checked in, and
@@ -2231,10 +2232,10 @@ update --init --recursive` gets them.
## 8. Status
**The port is done.** The C interpreter passes the language corpus and passes clean
**The port is done.** The C interpreter passes the Go reference's entire corpus and passes clean
under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the
requirement; two cases have diverged on purpose since, and
`tests/language/README.md` lists them.
`tests/reference/README.md` lists them.
| Gate | Result |
|---|---|
@@ -2242,7 +2243,7 @@ requirement; two cases have diverged on purpose since, and
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips |
| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention |
| `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half |
| Language corpus | 65/65 paired expectations — **and 65/65 again through the SDL binary**, which is most of what proves the frontend changes no output |
| Golden corpus | 41/41 byte-exact from `tests/reference/` — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
| ASan + UBSan | 112/112 |
| Line coverage | 94.1% (7227/7681) — above the 90% gate |
| Function coverage | 97.9% (474/484) |
@@ -2552,7 +2553,7 @@ What remains, in priority order:
this whole arrangement exists against.
The byte comparison is a deliberate bet that the dummy driver and the software renderer
are reproducible, which is the same bet `tests/language/` makes about golden
are reproducible, which is the same bet `tests/reference/` already makes about golden
output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade
that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same
commit as the bump** — do not weaken the test to a size check, which would pass for every
@@ -2635,7 +2636,7 @@ reduced against `build/basic`, the stdio build, unless it says otherwise.
**It is narrower than it looks, and the golden corpus is why.** The first attempt
released the scope on *any* skip, which broke
`tests/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero-
`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 is what absorbs the inner `NEXT` so the outer one still finds its
`FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the

View File

@@ -743,8 +743,8 @@ way to see that something pushed a scope and never popped it.
```sh norun
ctest --test-dir build --output-on-failure -R for_next # one unit test
ctest --test-dir build --output-on-failure -R local_ # the language corpus
./build/basic tests/language/functions.bas | diff - tests/language/functions.txt
ctest --test-dir build --output-on-failure -R golden_ # the reference corpus
./build/basic tests/reference/language/functions.bas | diff - tests/reference/language/functions.txt
./tests/docs_examples.sh --root . --basic ./build/basic \
--cflags-file build/docs_cflags.txt docs/14-architecture.md
```

View File

@@ -56,7 +56,10 @@ This is a tour of the interpreter's edges, on purpose:
counterpoint: a genuine 24x21 `SPRSAV` type-in sprite, 63 bytes, which is
everything `DATA` has room to say. The paint is the reveal, and the exit is a
stride-63 column dissolve — 63 is coprime to 160, so the walk hits every
column once and looks random while carrying no state.
column once and looks random while carrying no state. The strings come in
64-character chunks because a source line is 80 columns, like the machines
this pretends to be — the decoder carries its cursor from one `IM$` entry to
the next, and the generator refuses to cut inside a run.
- **And then the picture is video.** Six delta frames loop the floor grid toward
you and crawl the sun's slice pattern — full motion video, 1.3 KB total, about
220 bytes a frame. A delta re-encodes only the rows that changed: an `R`
@@ -82,6 +85,10 @@ This is a tour of the interpreter's edges, on purpose:
batch a bar of noise drums. Noise is real: `ENVELOPE`'s sixth argument is a
waveform, 0 to 3, which Chapter 7 forgot to mention. Voice 1 carries the tune so
the collision blips (voice 2) and scene sweeps (voice 3) never steal its channel.
A batch is four `PLAY` statements, one bar each — the 80-column line limit will
not hold four bars in one string, and it does not need to: the parser's voice,
envelope and duration state persists across statements and every `PLAY` appends
to the same queue, so four bars queue exactly as one line would.
It also probes for its hardware like a proper boot loader, with one `TRAP` per
device: no graphics refuses by name and exits, no audio mutes the soundtrack, no

View File

@@ -461,38 +461,78 @@ BS% = W# / 160
IF BS% < 1 THEN BS% = 1
REM ---- PICTURE-BEGIN (generated by vaporwave.py; do not
REM ---- hand-edit -- rerun the script to change the picture)
DIM IM$(16)
DIM IM$(56)
DIM VA#(6)
DIM VB#(6)
NS# = 9
VA#(0) = 9
VB#(0) = 9
VA#(1) = 10
VB#(1) = 10
VA#(2) = 11
VB#(2) = 12
VA#(3) = 13
VB#(3) = 13
VA#(4) = 14
VB#(4) = 14
VA#(5) = 15
VB#(5) = 15
IM$(0) = "G9G9G9G9GPGHEHG9GTEHG9GTEHGPGPEHG9GTEHG9GTEHGHGXEHGTBAG8EHG9GTEHG5EHGPEHG5EHGPEHG5EHGAPAG3EHGPEHG5EHGPEHGXGHEHG5EHGPEHG5EHGFBAGIEHGPGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEHGPEHGPEHGHEHGBPAGMEHGPEHGHEHGPEHGHEHGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEPGHEHGPEH"
IM$(1) = "GHEPGHEHGPEHGHEPGHEHGHEGBAEHGHEHGPEHGHEPGHEHGPEHGHEHGPEHGHEPGHEHGPEHGHEPGHEHGPECPAEDGHEPGHEHGHEPGHEPGHEHGHEPGHEPGHEHGHEPGHEHGHEPGHEPGHECBAEDGHEPGHEPGHEHGHEPGHEHGHEPGHEPGHEHGHEPGHEPEPGHEPGHE5GHEPGHE5GHEHEXGHEPGHEEPAEZGHEPGHE5GHE5GHEPGHE5GHEP"
IM$(2) = "GHE5GHE9ETGHE9ETGHEXEHGHE9ETGHHAE9ESGHEPEPGBBAGEE9EMHOE9ETGHEHE9E7HUE9E6E9E4H0E9E3E9E3H2E9EHBAETEHKHE9ELH6E9ECKHEPEPKHE9EBH9HAE9EIKHEHEXKHE2H9HCE9EPKHE5KHEPKDH9HEKCEPKHE5KHE5KHEKH9HGEBKHEPKHEXEHKHE5KHEBH9HIEIKHEPKHEPEPKHEPKHEHKAH9HKKHEHKHEP"
IM$(3) = "KHEHKHEPKHEPKHH9HMEGKHEHKHEPKHEHKHEPKHEPH9HMEOKHEHKHEPKHEHKPEHKHEGH9HOKFEPKHEHKPEHKHEHKPEHKGH9HOEFKHEPKHEHKHEPKHEHKPEFH9HQKEEHKHEPKHEHKHEPKHEHKMH9HSKLEHKHEPKHEHKPEHKHEHKEH9HSEDKPEHKPEHKHEHKPEHKHEEH9HSKDEHKPEHKPKPEHKPEHKDH9HUECKHEHKPEHKHKXEH"
IM$(4) = "KPEDH9HUKCEHK5EHK5EHKLH9HUKKEHK5EHK5EHKCH9HWEBKPEHKXKHEHK8I9IWKZEHKPKPEHK0I9IWK7EHKHKXEHKSI9IWK9KFEHK9KOI9IWK9KNK9KOI9IWK9KNK9KOI9IWK9KNKPIHKZI9IYK6IHKHKXIHKSI9IWK9KFIHK5IHKKI9IWK9KNIHK5IHKCI9IYKPIHKXKHIHK5IHKPIHK5IHKPIHKPKPIHK0I9IWKJIHKPIH"
IM$(5) = "KHIHKPIHKPI9IZKBIHKHIHKPIHKHIHKPIHKKI9IWKJIHKHIHKPIHKHIHKPIHKDI9IXKPIHKHIHKHKHIHKHIPKHI9IYKCIHKPIHKHIHKPIHKHIPKHIHKPIHKHIPKHIHKPIHKHIHKPIHKHI9I9IHKHIHKPIHKHIPKHIHKHIEC9CSKDIPKHIPKHIHKHIPKHIHKEC9CSIDKHIPKHIPIPKHIPKHIFC9CQKEIHKHIPKHIHIXKHIPKG"
IM$(6) = "C9COIFKHI5KHI5KHIPKHI5KHIPKHI5KHI5KHIPKHI5KHIPKHIXIHKHI9IDC9CMI4KHIPIPKHI6C9CKI9IDKHIHIXKHIZC9CII9IMKHI9IWC9CGI9IVI9I9I9I9IPI9I9I9I9IPQ9Q9Q9Q9QPE9E9E9E9EPE9E9E9E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9"
IM$(7) = "Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9"
IM$(8) = "Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QP"
IM$(9) = "RBRQ9QOKMQHK5RBSQ9QTI9IRRBXQ9QTKPQHKHQPKERBYQ9QPI9IHRB3Q9QSKAI5KHIJRB5Q9QTC9CMRB9Q9QWI9IGRCBQ9QYC9CCRCIA9A9A9A9APRCJE9E9E9E9EPRCNA9A9A9A9APRCOE9E9E9E9EPRCUA9A9A9A9APRCVE9E9E9E9EPRC5A9A9A9A9APRC7E9E9E9E9EP"
IM$(10) = "RBQQ9QOK9KIQHKFRBRQ9QOI9IQRBWQ9QPKLQHKHQHKPRBXQ9QTI9QLIERB2Q9QRIBKHIPKHIPKCRB4Q9QSC9CORB8Q9QVI3KHIGRCAQ9QXC9CERCJA9A9A9A9APRCKE9E9E9E9EPRCOA9A9A9A9APRCPE9E9E9E9EPRCVA9A9A9A9APRCXE9E9E9E9EPRC7A9A9A9A9APRDAE9E9E9E9EP"
IM$(11) = "RBPQ9QOK9KAQHKNRBQQ9QOI9IWRBVQ9QOKEQHKHQHKPQHKFRBWQ9QPI9IPRB1Q9QQKCIPKHIPKHIDRB3Q9QSC9CORB7Q9QUIWKHIPRB9Q9QWC9CGRCEA9A9A9A9APRCFE9E9E9E9EPRCGA9A9A9A9APRCHE9E9E9E9EPRCPA9A9A9A9APRCQE9E9E9E9EPRCXA9A9A9A9APRCZE9E9E9E9EPRDAA9A9A9A9APRDCE9E9E9E9"
IM$(12) = "EP"
IM$(13) = "RBOQ9QNK3QHKWRBPQ9QOI9IWRBUQ9QTKHQHKPQHKNRBVQ9QOI9IWRB0Q9QQIKKHIPKHIHKDRB1Q9QQC9CSRB2Q9QRC9CQRB6Q9QTIPKHIYRB8Q9QVC9CIRCKA9A9A9A9APRCLE9E9E9E9EPRCQA9A9A9A9APRCRE9E9E9E9EPRCZA9A9A9A9APRC1E9E9E9E9EPRDCA9A9A9A9APRDFE9E9E9E9EP"
IM$(14) = "RBOQ9QNI9IYRBTQ9QOKEQHKPQHKVRBUQ9QTI9IRRBZQ9QTKHQHKPQHKHRB0Q9QQC9CSRB5Q9QTIHKHI5KARB7Q9QUC9CKRCBQ9QYI9ICRCHA9A9A9A9APRCIE9E9E9E9EPRCLA9A9A9A9APRCME9E9E9E9EPRCRA9A9A9A9APRCTE9E9E9E9EPRC1A9A9A9A9APRC3E9E9E9E9EPRDFA9A9A9A9APRDIE9E9E9E9EP"
IM$(15) = "RBSQ9QTKPQHK3RBTQ9QOI9IWRBYQ9QPKDQHKPQHKHRBZQ9QTI9ILRB4Q9QSIAKHI5KHIBRB6Q9QTC9CMRCAQ9QXI9IERCEE9E9E9E9EPRCFA9A9A9A9APRCGE9E9E9E9EPRCMA9A9A9A9APRCNE9E9E9E9EPRCTA9A9A9A9APRCUE9E9E9E9EPRC3A9A9A9A9APRC5E9E9E9E9EPRDIA9A9A9A9AP"
NS# = 32
VA#(0) = 32
VB#(0) = 35
VA#(1) = 36
VB#(1) = 39
VA#(2) = 40
VB#(2) = 43
VA#(3) = 44
VB#(3) = 47
VA#(4) = 48
VB#(4) = 51
VA#(5) = 52
VB#(5) = 55
IM$(0) = "G9G9G9G9GPGHEHG9GTEHG9GTEHGPGPEHG9GTEHG9GTEHGHGXEHGTBAG8EHG9GTEH"
IM$(1) = "G5EHGPEHG5EHGPEHG5EHGAPAG3EHGPEHG5EHGPEHGXGHEHG5EHGPEHG5EHGFBAGI"
IM$(2) = "EHGPGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEHGPEHGPEHGHEHGBPAGMEHGPEHGHEH"
IM$(3) = "GPEHGHEHGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEPGHEHGPEHGHEPGHEHGPEHGHEP"
IM$(4) = "GHEHGHEGBAEHGHEHGPEHGHEPGHEHGPEHGHEHGPEHGHEPGHEHGPEHGHEPGHEHGPEC"
IM$(5) = "PAEDGHEPGHEHGHEPGHEPGHEHGHEPGHEPGHEHGHEPGHEHGHEPGHEPGHECBAEDGHEP"
IM$(6) = "GHEPGHEHGHEPGHEHGHEPGHEPGHEHGHEPGHEPEPGHEPGHE5GHEPGHE5GHEHEXGHEP"
IM$(7) = "GHEEPAEZGHEPGHE5GHE5GHEPGHE5GHEPGHE5GHE9ETGHE9ETGHEXEHGHE9ETGHHA"
IM$(8) = "E9ESGHEPEPGBBAGEE9EMHOE9ETGHEHE9E7HUE9E6E9E4H0E9E3E9E3H2E9EHBAET"
IM$(9) = "EHKHE9ELH6E9ECKHEPEPKHE9EBH9HAE9EIKHEHEXKHE2H9HCE9EPKHE5KHEPKDH9"
IM$(10) = "HEKCEPKHE5KHE5KHEKH9HGEBKHEPKHEXEHKHE5KHEBH9HIEIKHEPKHEPEPKHEPKH"
IM$(11) = "EHKAH9HKKHEHKHEPKHEHKHEPKHEPKHH9HMEGKHEHKHEPKHEHKHEPKHEPH9HMEOKH"
IM$(12) = "EHKHEPKHEHKPEHKHEGH9HOKFEPKHEHKPEHKHEHKPEHKGH9HOEFKHEPKHEHKHEPKH"
IM$(13) = "EHKPEFH9HQKEEHKHEPKHEHKHEPKHEHKMH9HSKLEHKHEPKHEHKPEHKHEHKEH9HSED"
IM$(14) = "KPEHKPEHKHEHKPEHKHEEH9HSKDEHKPEHKPKPEHKPEHKDH9HUECKHEHKPEHKHKXEH"
IM$(15) = "KPEDH9HUKCEHK5EHK5EHKLH9HUKKEHK5EHK5EHKCH9HWEBKPEHKXKHEHK8I9IWKZ"
IM$(16) = "EHKPKPEHK0I9IWK7EHKHKXEHKSI9IWK9KFEHK9KOI9IWK9KNK9KOI9IWK9KNK9KO"
IM$(17) = "I9IWK9KNKPIHKZI9IYK6IHKHKXIHKSI9IWK9KFIHK5IHKKI9IWK9KNIHK5IHKCI9"
IM$(18) = "IYKPIHKXKHIHK5IHKPIHK5IHKPIHKPKPIHK0I9IWKJIHKPIHKHIHKPIHKPI9IZKB"
IM$(19) = "IHKHIHKPIHKHIHKPIHKKI9IWKJIHKHIHKPIHKHIHKPIHKDI9IXKPIHKHIHKHKHIH"
IM$(20) = "KHIPKHI9IYKCIHKPIHKHIHKPIHKHIPKHIHKPIHKHIPKHIHKPIHKHIHKPIHKHI9I9"
IM$(21) = "IHKHIHKPIHKHIPKHIHKHIEC9CSKDIPKHIPKHIHKHIPKHIHKEC9CSIDKHIPKHIPIP"
IM$(22) = "KHIPKHIFC9CQKEIHKHIPKHIHIXKHIPKGC9COIFKHI5KHI5KHIPKHI5KHIPKHI5KH"
IM$(23) = "I5KHIPKHI5KHIPKHIXIHKHI9IDC9CMI4KHIPIPKHI6C9CKI9IDKHIHIXKHIZC9CI"
IM$(24) = "I9IMKHI9IWC9CGI9IVI9I9I9I9IPI9I9I9I9IPQ9Q9Q9Q9QPE9E9E9E9EPE9E9E9"
IM$(25) = "E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QP"
IM$(26) = "Q9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9"
IM$(27) = "Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9"
IM$(28) = "QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9"
IM$(29) = "Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9"
IM$(30) = "Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QP"
IM$(31) = "Q9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QP"
IM$(32) = "RBRQ9QOKMQHK5RBSQ9QTI9IRRBXQ9QTKPQHKHQPKERBYQ9QPI9IHRB3Q9QSKAI5"
IM$(33) = "KHIJRB5Q9QTC9CMRB9Q9QWI9IGRCBQ9QYC9CCRCIA9A9A9A9APRCJE9E9E9E9EP"
IM$(34) = "RCNA9A9A9A9APRCOE9E9E9E9EPRCUA9A9A9A9APRCVE9E9E9E9EPRC5A9A9A9A9"
IM$(35) = "APRC7E9E9E9E9EP"
IM$(36) = "RBQQ9QOK9KIQHKFRBRQ9QOI9IQRBWQ9QPKLQHKHQHKPRBXQ9QTI9QLIERB2Q9QR"
IM$(37) = "IBKHIPKHIPKCRB4Q9QSC9CORB8Q9QVI3KHIGRCAQ9QXC9CERCJA9A9A9A9APRCK"
IM$(38) = "E9E9E9E9EPRCOA9A9A9A9APRCPE9E9E9E9EPRCVA9A9A9A9APRCXE9E9E9E9EP"
IM$(39) = "RC7A9A9A9A9APRDAE9E9E9E9EP"
IM$(40) = "RBPQ9QOK9KAQHKNRBQQ9QOI9IWRBVQ9QOKEQHKHQHKPQHKFRBWQ9QPI9IPRB1Q9"
IM$(41) = "QQKCIPKHIPKHIDRB3Q9QSC9CORB7Q9QUIWKHIPRB9Q9QWC9CGRCEA9A9A9A9AP"
IM$(42) = "RCFE9E9E9E9EPRCGA9A9A9A9APRCHE9E9E9E9EPRCPA9A9A9A9APRCQE9E9E9E9"
IM$(43) = "EPRCXA9A9A9A9APRCZE9E9E9E9EPRDAA9A9A9A9APRDCE9E9E9E9EP"
IM$(44) = "RBOQ9QNK3QHKWRBPQ9QOI9IWRBUQ9QTKHQHKPQHKNRBVQ9QOI9IWRB0Q9QQIKKH"
IM$(45) = "IPKHIHKDRB1Q9QQC9CSRB2Q9QRC9CQRB6Q9QTIPKHIYRB8Q9QVC9CIRCKA9A9A9"
IM$(46) = "A9APRCLE9E9E9E9EPRCQA9A9A9A9APRCRE9E9E9E9EPRCZA9A9A9A9APRC1E9E9"
IM$(47) = "E9E9EPRDCA9A9A9A9APRDFE9E9E9E9EP"
IM$(48) = "RBOQ9QNI9IYRBTQ9QOKEQHKPQHKVRBUQ9QTI9IRRBZQ9QTKHQHKPQHKHRB0Q9QQ"
IM$(49) = "C9CSRB5Q9QTIHKHI5KARB7Q9QUC9CKRCBQ9QYI9ICRCHA9A9A9A9APRCIE9E9E9"
IM$(50) = "E9EPRCLA9A9A9A9APRCME9E9E9E9EPRCRA9A9A9A9APRCTE9E9E9E9EPRC1A9A9"
IM$(51) = "A9A9APRC3E9E9E9E9EPRDFA9A9A9A9APRDIE9E9E9E9EP"
IM$(52) = "RBSQ9QTKPQHK3RBTQ9QOI9IWRBYQ9QPKDQHKPQHKHRBZQ9QTI9ILRB4Q9QSIAKH"
IM$(53) = "I5KHIBRB6Q9QTC9CMRCAQ9QXI9IERCEE9E9E9E9EPRCFA9A9A9A9APRCGE9E9E9"
IM$(54) = "E9EPRCMA9A9A9A9APRCNE9E9E9E9EPRCTA9A9A9A9APRCUE9E9E9E9EPRC3A9A9"
IM$(55) = "A9A9APRC5E9E9E9E9EPRDIA9A9A9A9AP"
REM ---- PICTURE-END
SS# = 0
SE# = NS# - 1
@@ -1317,13 +1357,25 @@ TB# = TB# + 1
IF TB# > 2 THEN TB# = 0
RETURN
REM A batch is four PLAY statements, one bar each: the parser's V, T,
REM U and duration state persists across statements and every PLAY
REM appends to the same queue, so four bars queue exactly as one long
REM string would -- which the 80-column line limit no longer allows.
REM Each bar restates the prefix anyway, so a bar dropped by QFULL
REM never leaves the next one playing with drum-kit state.
LABEL TUNEA
PLAY "V1T3U9S O1AO2AO3AO2AO3AO4CEAO1AO2AO4CEAO5CO4AE O1FO2FO3FO2FO3FAO4CFO1FO2FO3AO4CFAO5CO4A O1CO2CO3CO2CO3CEGO4CO1CO2CO3EGO4CEGO5C O1GO2GO3GO2GO3GBO4DGO1GO2GO3BO4DGBO5DO4B"
PLAY "V1T3U9S O1AO2AO3AO2AO3AO4CEAO1AO2AO4CEAO5CO4AE"
PLAY "V1T3U9S O1FO2FO3FO2FO3FAO4CFO1FO2FO3AO4CFAO5CO4A"
PLAY "V1T3U9S O1CO2CO3CO2CO3CEGO4CO1CO2CO3EGO4CEGO5C"
PLAY "V1T3U9S O1GO2GO3GO2GO3GBO4DGO1GO2GO3BO4DGBO5DO4B"
MT# = TI# + 270
RETURN
LABEL TUNEB
PLAY "V1T3U9S O1AO2AO3AO2AO3AO4CEAO1AO2AO4CEAO5CEO4A O1FO2FO3FO2FO3FAO4CFO1FO2FO3AO4CFAO5CO4A O1DO2DO3DO2DO3DFAO4DO1DO2DO3FAO4DFAO5D O1EO2EO3EO2EO3E#GBO4EO1EO2EO3#GBO4E#GBO5E"
PLAY "V1T3U9S O1AO2AO3AO2AO3AO4CEAO1AO2AO4CEAO5CEO4A"
PLAY "V1T3U9S O1FO2FO3FO2FO3FAO4CFO1FO2FO3AO4CFAO5CO4A"
PLAY "V1T3U9S O1DO2DO3DO2DO3DFAO4DO1DO2DO3FAO4DFAO5D"
PLAY "V1T3U9S O1EO2EO3EO2EO3E#GBO4EO1EO2EO3#GBO4E#GBO5E"
MT# = TI# + 270
RETURN

View File

@@ -58,7 +58,14 @@ SKIP = "Q"
ROWREC = "R"
LENCH = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
MAXRUN = len(LENCH)
PAYLOAD = 240
# The interpreter reads source through an 80-byte line buffer and refuses
# any line that fills it (AKBASIC_MAX_LINE_LENGTH, sink_stdio.c), so a
# stored line is at most 78 characters plus its newline. 'IM$(NN) = "' and
# the closing quote spend 12 of those; 64 keeps the emitted lines under
# the ceiling with margin to spare while the index stays two digits.
PAYLOAD = 64
MAXLINE = 78
SUN_CX, SUN_CY, SUN_R = 80, 50, 30
HORIZON = 74
@@ -206,13 +213,32 @@ def encode_delta(prev, cur):
def chop(blob):
return [blob[i:i + PAYLOAD] for i in range(0, len(blob), PAYLOAD)]
"""Split a stream into strings of at most PAYLOAD characters, cutting
only between records. The decoder reads a record's tail characters
with MID on the string it is walking, so a run (two characters) or a
row record (three) that straddled two IM$ entries would decode as
garbage; DRAWSTREAM only carries the cursor, never a partial record."""
out, cur = [], ""
p = 0
while p < len(blob):
n = 3 if blob[p] == ROWREC else 2
if len(cur) + n > PAYLOAD:
out.append(cur)
cur = ""
cur += blob[p:p + n]
p += n
if cur:
out.append(cur)
return out
def simulate(raster, blob):
def simulate(raster, blob, x=0, y=0):
"""Apply one encoded stream to a raster exactly the way the BASIC
decoder does, skips-draw-nothing and all."""
x = y = p = 0
decoder does, skips-draw-nothing and all. The cursor comes in and
goes back out because DRAWSTREAM carries it from one IM$ entry to
the next -- decoding the chopped strings one at a time with the
cursor threaded through is exactly what the demo will execute."""
p = 0
while p < len(blob):
c = blob[p]
if c == ROWREC:
@@ -230,20 +256,31 @@ def simulate(raster, blob):
x = 0
y += 1
p += 2
return raster, x, y
def simulate_lines(raster, lines):
"""One stream as its chopped strings, cursor carried across the
boundaries the way DRAWSTREAM carries X# and Y#."""
x = y = 0
for line in lines:
raster, x, y = simulate(raster, line, x, y)
return raster
def verify(frames, base_blob, delta_blobs):
def verify(frames, base_lines, delta_line_groups):
"""The base must reproduce frame 0 exactly, and each delta must
carry the raster exactly to the next frame. A skip leaves the cell
the encoder promised was already right, so equality is total and
any difference at all is an encoder bug."""
any difference at all is an encoder bug. This decodes the CHOPPED
strings, not the blobs, so a chop that split a record would fail
here instead of corrupting the screen."""
raster = [[1] * W for _ in range(H)]
raster = simulate(raster, base_blob)
raster = simulate_lines(raster, base_lines)
assert raster == frames[0], "base stream does not reproduce frame 0"
for i, blob in enumerate(delta_blobs):
for i, lines in enumerate(delta_line_groups):
want = frames[(i + 1) % PHASES]
raster = simulate(raster, blob)
raster = simulate_lines(raster, lines)
assert raster == want, "delta %d does not reproduce its frame" % i
@@ -261,6 +298,9 @@ def emit_block(base_lines, delta_ranges, all_lines):
for i, s in enumerate(all_lines):
out.append('IM$(%d) = "%s"' % (i, s))
out.append("REM ---- PICTURE-END")
for line in out:
assert len(line) <= MAXLINE, "emitted line over %d chars: %r" % (
MAXLINE, line)
return out
@@ -303,15 +343,15 @@ def main():
base_lines = chop(base_blob)
all_lines = list(base_lines)
delta_ranges = []
delta_blobs = []
delta_line_groups = []
for i in range(PHASES):
blob = encode_delta(frames[i], frames[(i + 1) % PHASES])
delta_blobs.append(blob)
lines = chop(blob)
delta_line_groups.append(lines)
delta_ranges.append((len(all_lines), len(all_lines) + len(lines) - 1))
all_lines.extend(lines)
verify(frames, base_blob, delta_blobs)
dbytes = sum(len(b) for b in delta_blobs)
verify(frames, base_lines, delta_line_groups)
dbytes = sum(len(l) for g in delta_line_groups for l in g)
print("base %d bytes in %d strings; video %d bytes in %d strings; "
"%d strings total" %
(sum(len(s) for s in base_lines), len(base_lines), dbytes,

View File

@@ -211,8 +211,12 @@ typedef struct akbasic_Runtime
* not exist relative to the working directory, which is what makes a `.bas`
* beside its art work from anywhere. Group F's disk verbs will want the
* same, which is why it is on the runtime rather than in the sprite state.
*
* Sized to AKBASIC_MAX_SOURCE_PATH_LENGTH, not AKBASIC_MAX_LINE_LENGTH: a
* directory is a filesystem path, not a line of BASIC, and the two do not
* belong to the same budget.
*/
char sourcepath[AKBASIC_MAX_LINE_LENGTH];
char sourcepath[AKBASIC_MAX_SOURCE_PATH_LENGTH];
/*
* The armed interrupts, and the environment the one currently running was
@@ -365,7 +369,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_ui(akbasic_Runtime *obj,
* @param path Path to the program file, or NULL for none.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKBASIC_ERR_BOUNDS When the path is longer than AKBASIC_MAX_LINE_LENGTH.
* @throws AKBASIC_ERR_BOUNDS When the path is longer than AKBASIC_MAX_SOURCE_PATH_LENGTH.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_source_path(akbasic_Runtime *obj, const char *path);

View File

@@ -23,9 +23,17 @@
* serves variables, functions and labels. Capacity is a member; the table is
* kept below a 75% load factor by construction because `capacity` counts slots
* and the caller's logical maximum is smaller.
*
* 172 rather than the old 256: no caller passes akbasic_symtab_init() anything
* larger than AKBASIC_MAX_VARIABLES (128), and 172 keeps that under the 75%
* load factor the comment above promises (128 / 0.75 = 170.7).
*
* AKBASIC_SYMTAB_MAX_KEY 24 rather than 64: the longest identifier across
* examples/breakout and examples/megademo -- variables, DIMmed arrays, labels
* and DEF FN names alike -- is 11 characters (TITLESCREEN, CLEARPOWERS).
*/
#define AKBASIC_SYMTAB_MAX_SLOTS 256
#define AKBASIC_SYMTAB_MAX_KEY 64
#define AKBASIC_SYMTAB_MAX_SLOTS 172
#define AKBASIC_SYMTAB_MAX_KEY 24
typedef struct
{

View File

@@ -6,6 +6,7 @@
#ifndef _AKBASIC_TYPES_H_
#define _AKBASIC_TYPES_H_
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
@@ -27,15 +28,39 @@
#define AKBASIC_MAX_VALUES 64
#define AKBASIC_MAX_VARIABLES 128
/* Whole-runtime pools */
#define AKBASIC_MAX_SOURCE_LINES 9999
#define AKBASIC_MAX_LINE_LENGTH 256
/*
* Whole-runtime pools.
*
* AKBASIC_MAX_SOURCE_LINES, AKBASIC_MAX_LINE_LENGTH, AKBASIC_MAX_ARRAY_VALUES,
* AKBASIC_MAX_ENVIRONMENTS and AKBASIC_MAX_FUNCTIONS were cut from their
* original values against measurements taken off examples/breakout and
* examples/megademo, the two most demanding programs this interpreter runs --
* see the memory-footprint discussion this commit's PR body links. Each is
* sized at roughly 1.5-2x the peak the reference corpus actually reaches, not
* at the peak itself.
*
* AKBASIC_MAX_LINE_LENGTH in particular follows Commodore BASIC's own 80-column
* line limit rather than a measurement, which is why sink_stdio.c now refuses a
* line that fills the buffer with no terminator instead of silently truncating
* it: at 256 bytes that failure mode was theoretical, and at 80 it is not.
*
* AKBASIC_MAX_SOURCE_PATH_LENGTH is its own constant rather than a reuse of
* AKBASIC_MAX_LINE_LENGTH, which is where it used to come from. `sourcepath`
* (runtime.h) holds a directory, not a line of BASIC, and the two ideas do not
* scale together: shrinking the line limit to 80 broke every golden test in
* this checkout, because this repository's own working directory is deeper
* than that. PATH_MAX is the actual bound a filesystem path is subject to, so
* it is the one this borrows.
*/
#define AKBASIC_MAX_SOURCE_LINES 2048
#define AKBASIC_MAX_LINE_LENGTH 80 /* Commodore BASIC's own line limit */
#define AKBASIC_MAX_SOURCE_PATH_LENGTH PATH_MAX
#define AKBASIC_MAX_ARRAY_DEPTH 64 /* dimensions per array */
#define AKBASIC_MAX_ARRAY_ELEMENTS 1024 /* elements in one array */
#define AKBASIC_MAX_ARRAY_VALUES 4096 /* array elements across all variables */
#define AKBASIC_MAX_ARRAY_VALUES 2048 /* array elements across all variables */
#define AKBASIC_MAX_STRING_LENGTH 256 /* see TODO.md 1.2 */
#define AKBASIC_MAX_ENVIRONMENTS 32 /* new: Go allocated these unbounded */
#define AKBASIC_MAX_FUNCTIONS 64 /* new: Go used an unbounded map */
#define AKBASIC_MAX_ENVIRONMENTS 12 /* new: Go allocated these unbounded */
#define AKBASIC_MAX_FUNCTIONS 8 /* new: Go used an unbounded map */
#define AKBASIC_MAX_LABELS 64 /* new: Go used an unbounded map */
/*
* Leaves a DO/LOOP condition may use. Its own small pool rather than a second

View File

@@ -13,12 +13,22 @@
#include <akerror.h>
#include <akbasic/symtab.h>
#include <akbasic/types.h>
#include <akbasic/value.h>
typedef struct
{
char name[AKBASIC_MAX_STRING_LENGTH];
/*
* Sized to AKBASIC_SYMTAB_MAX_KEY, not AKBASIC_MAX_STRING_LENGTH: this name
* only ever gets here by surviving akbasic_symtab_set() first
* (akbasic_environment_create() calls it right after this field is
* populated), and that call refuses anything AKBASIC_SYMTAB_MAX_KEY
* characters or longer with AKBASIC_ERR_BOUNDS. A variable whose name did
* not fit could never exist, so the wider buffer was 232 bytes of headroom
* nothing could ever put a byte into.
*/
char name[AKBASIC_SYMTAB_MAX_KEY];
akbasic_Type valuetype;
akbasic_Value *values; /** The pool, or `inlinevalue` for a scalar */
int valuecount;

View File

@@ -282,8 +282,9 @@ def copy_tree(src, dst):
# and a coverage tree drags along every .gcno/.gcda as well.
#
# deps/ is copied wholesale and has to be: the build pulls libakerror and
# libakstdlib in with add_subdirectory. The language corpus is the other
# reason it comes along with the rest of the tree.
# libakstdlib in with add_subdirectory. The golden corpus used to be the
# other reason -- it was driven in place out of deps/basicinterpret -- and
# now lives in tests/reference/, which comes along with the rest of the tree.
# Only vendored Windows DLLs are dead weight, hence "*.dll".
#
# **PNGs are ours or theirs, and the difference matters.** This used to drop

View File

@@ -301,7 +301,7 @@ akerr_ErrorContext *akbasic_runtime_set_source_path(akbasic_Runtime *obj, const
}
FAIL_ZERO_RETURN(errctx, (length < sizeof(obj->sourcepath)), AKBASIC_ERR_BOUNDS,
"Program path of %zu characters exceeds the %d character limit",
length, AKBASIC_MAX_LINE_LENGTH - 1);
length, AKBASIC_MAX_SOURCE_PATH_LENGTH - 1);
PASS(errctx, aksl_memcpy(obj->sourcepath, path, length));
obj->sourcepath[length] = '\0';
SUCCEED_RETURN(errctx);
@@ -893,7 +893,7 @@ akerr_ErrorContext *akbasic_runtime_interpret(akbasic_Runtime *obj, akbasic_ASTL
*
* **Only a block skip**, which is what the `BEND` test is for. A
* zero-iteration `FOR` skips its body the same way, and there the
* orphan is load-bearing: `tests/language/.../nestedforloopwaiting
* orphan is load-bearing: `tests/reference/.../nestedforloopwaiting
* forcommand.bas` nests a loop inside one that runs zero times, and
* the inner scope is what absorbs the inner `NEXT` so the outer `NEXT`
* still finds its `FOR`. Popping it there turns that case into "NEXT
@@ -1262,7 +1262,7 @@ akerr_ErrorContext *akbasic_runtime_process_line_runstream(akbasic_Runtime *obj)
* This mode used to file it like any other, under the cursor -- which for a
* blank line is the number of the line *before* it. A file ending in a blank
* line therefore had its last line erased before it ever ran, silently. The
* reference did the same, and `tests/language/arithmetic/integer.bas`
* reference did the same, and `tests/reference/language/arithmetic/integer.bas`
* has an expectation with three values for four PRINT statements to prove it.
*/
if ( buffer[0] == '\0' ) {

View File

@@ -74,6 +74,19 @@ static akerr_ErrorContext *stdio_readline(akbasic_TextSink *self, char *dest, si
if ( *eof ) {
SUCCEED_RETURN(errctx);
}
/*
* aksl_fgets(3)'s own contract: a full buffer with no trailing newline is
* how a caller spots a line longer than the buffer, because the rest of it
* is still sitting unread in the stream. Refusing here is what makes that
* true -- without it, the unread remainder is picked up by the *next*
* readline() as if it were its own statement, which does not fail, it just
* runs the wrong program. AKBASIC_MAX_LINE_LENGTH is small enough now that
* this is not a hypothetical: examples/breakout's own longest line used to
* clear the old 256-byte ceiling by more than half.
*/
FAIL_NONZERO_RETURN(errctx, (used == len - 1 && dest[used - 1] != '\n' && dest[used - 1] != '\r'),
AKBASIC_ERR_BOUNDS,
"Source line exceeds the %zu character limit", len - 1);
/*
* Strip the line terminator. The scanner treats \r and \n as end-of-line
* anyway, but leaving them on would make a stored source line differ from

View File

@@ -81,7 +81,7 @@ static void test_counter_survives_the_loop(void)
* `FOR I = 1 TO 1` executes the body one time on every BASIC there has ever
* been. Here the entry test treats "the counter has reached the limit" as
* "do not enter", so the body is skipped entirely -- and
* tests/language/flowcontrol/forloopwaitingforcommand.bas pins that,
* tests/reference/language/flowcontrol/forloopwaitingforcommand.bas pins that,
* which is why this cannot simply be corrected.
*/
static void test_single_iteration_loop(void)

View File

@@ -1,16 +0,0 @@
# The language test corpus
The `.bas` files in this directory and their sibling `.txt` files are the editable language
corpus. CMake registers every program as an individual `local_*` CTest case and compares its
output with the sibling expectation.
The corpus includes cases carried over from the deprecated Go implementation as well as cases
written for this interpreter. Their provenance is useful when investigating a regression, but it
does not make any case immutable or turn the old implementation into a specification.
**Change a program and its expectation deliberately and in the same commit.** If the changed
output represents an intentional language decision, record the reason in `TODO.md` or the
relevant documentation. If it is an accidental change, fix the interpreter instead.
The `examples/` subdirectory contains complete BASIC programs used as worked examples. It is
part of the same corpus and follows the same `.bas`/`.txt` pairing rule.

63
tests/reference/README.md Normal file
View File

@@ -0,0 +1,63 @@
# The reference interpreter's own test corpus
These 41 `.bas` files and their 41 `.txt` expectations are **not this project's tests.** They
are a byte-for-byte copy of the acceptance corpus belonging to the Go implementation this
interpreter was ported from:
| | |
|---|---|
| Source | `https://source.starfort.tech/andrew/basicinterpreter`, `tests/` |
| Copied at | commit `d76162cb37eeaccddae2cf6cab7af7bc8175d6d2` ("Update README.md") |
| Copied on | 2026-07-31 |
| Verified | every file `cmp`-identical to the submodule at the time of the copy |
They are registered as the `golden_*` CTest cases and run against `basic` on every build, in
both the default and the `-DAKBASIC_WITH_AKGL=ON` configurations.
## Why they are a copy, when they deliberately were not
Until 2026-07-31 these were driven *in place* out of `deps/basicinterpret`, and the reasoning
was written down in `CMakeLists.txt`: the corpus is a submodule, and copying a submodule's
corpus guarantees drift.
That reasoning was sound and it stopped applying. The Go project is deprecated and will not be
updated, so there is nothing left to drift *from* — and a build that cannot run its own
acceptance suite without cloning the implementation it replaced is not finished.
## What these are for now
**A regression suite, not a specification.** That distinction is the whole of how to treat
them, and it changed on 2026-07-31 (`TODO.md` §0.1): matching the Go implementation is no
longer a goal, so a difference between it and this interpreter is not automatically a bug in
this one.
What they are still good for is unchanged and is worth a lot: forty-one real BASIC programs
with known-good output, covering arithmetic, arrays, flow control, every builtin function,
`READ`/`DATA`, labels and three worked examples. That catches regressions whatever its
provenance.
## The rule for editing these
**Diverge deliberately, never by accident.**
A failure here means this interpreter's behaviour changed. Nine times in ten that is a bug you
just introduced and the answer is to fix the code. The tenth time it is an improvement on the
reference, and then the answer is:
1. Change the expectation in the same commit as the code.
2. Say why in the commit message.
3. Add a line to `TODO.md` §5, which is the list of deliberate deviations.
What is not acceptable is editing a `.txt` to make a red suite green without deciding which of
the two you are doing.
New cases for this project's own behaviour go in `tests/language/`, registered under `local_*`.
The prefix says which corpus a failure came from, and a diff touching `tests/reference/` stands
out as the thing it is.
## Divergences so far
| Case | Change | Why |
|---|---|---|
| `language/arithmetic/integer.txt` | Expectation grew from `4 4 2` to `4 4 2 2` | The program has **four** `PRINT` statements and the expectation had three values. The file ends in a blank line, and both interpreters filed a blank line under the loader's cursor — which for a line with no number of its own is the number of the line before it. So `40 PRINT 4 - 2` was erased before the program ran. The reference lost it the same way, which is why the expectation was written short. Fixed by skipping blank lines in RUNSTREAM, as `akbasic_runtime_load()` and `DLOAD` already did. `TODO.md` §5. |
| `examples/strreverse.bas` | Variable `INPUT$` renamed to `SOURCE$` | `INPUT$` is a reserved word with a type suffix, which a real C128 refuses and which this interpreter now refuses too (`TODO.md` §6 item 16). The reference accepted it only because its own reserved-word check never fired. The expectation is byte-for-byte unchanged — the program still prints `REVERSED: OLLEH` — so the case keeps all of its value. |

Some files were not shown because too many files have changed in this diff Show More