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>
This commit is contained in:
@@ -20,6 +20,7 @@ include(CTest)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
option(AKBASIC_WITH_AKGL "Build the libakgl-backed text sink and link SDL3" OFF)
|
||||
option(AKBASIC_BUILD_EXAMPLES "Build the embedding example in examples/" ON)
|
||||
option(AKBASIC_COVERAGE "Instrument the build with gcov coverage counters" OFF)
|
||||
option(AKBASIC_SANITIZE "Build with ASan + UBSan" OFF)
|
||||
|
||||
@@ -153,6 +154,16 @@ if(AKBASIC_WITH_AKGL)
|
||||
endif()
|
||||
akbasic_instrument(basic)
|
||||
|
||||
# The embedding example. It is built by default and registered as a test so the
|
||||
# code README.md quotes cannot rot: a signature change breaks the build.
|
||||
if(AKBASIC_BUILD_EXAMPLES)
|
||||
add_executable(akbasic_example_embed examples/embed.c)
|
||||
target_compile_options(akbasic_example_embed PRIVATE -Wall -Wextra)
|
||||
target_link_libraries(akbasic_example_embed PRIVATE akbasic)
|
||||
akbasic_instrument(akbasic_example_embed)
|
||||
_add_test(NAME example_embed COMMAND akbasic_example_embed)
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests.
|
||||
#
|
||||
|
||||
312
README.md
Normal file
312
README.md
Normal file
@@ -0,0 +1,312 @@
|
||||
This BASIC is styled after [Commodore BASIC 7.0](http://www.jbrain.com/pub/cbm/manuals/128/C128PRG.pdf) and the [Dartmouth BASIC from 1964](https://www.dartmouth.edu/basicfifty/basic.html). It is a C rewrite of [basicinterpreter](https://source.starfort.tech/andrew/basicinterpreter), which was itself built from the instructions for the Java implementation of Lox in [craftinginterpreters.com](https://craftinginterpreters.com) before striking off on its own. The Go version is vendored at `deps/basicinterpret` and is the behavioural specification: when a question about semantics comes up, the answer lives in that code and in its `tests/`.
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
cmake -S . -B build
|
||||
cmake --build build --parallel
|
||||
|
||||
# To use the interactive REPL
|
||||
./build/basic
|
||||
|
||||
# To run a basic file from the command line
|
||||
./build/basic deps/basicinterpret/tests/language/functions.bas
|
||||
|
||||
# The test suite: unit tests plus the Go version's own corpus, byte-compared
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
# Why rewrite it in C?
|
||||
|
||||
Three reasons, in the order they matter.
|
||||
|
||||
The interpreter is meant to end up *inside* [libakgl](https://source.starfort.tech/andrew/libakgl) as a scripting engine for game authors, and libakgl is C. Embedding a Go runtime in a C game is not a thing anybody should do to themselves.
|
||||
|
||||
The Go version was already written against static pools and explicit state structs — `[MAX_SOURCE_LINES]BasicSourceLine`, a fixed variable pool, a 32-leaf ceiling per line — so it ports across almost directly. It reads like C that happens to be spelled in Go. Rewriting it in the idiom of `libakerror` and `libakstdlib` was less work than it sounds.
|
||||
|
||||
And the port is a good excuse to find out what the original actually does, as opposed to what it looks like it does. It found five defects nobody knew about. See "What Isn't Implemented / Isn't Working", below.
|
||||
|
||||
# What Works?
|
||||
|
||||
Everything the Go version does. All 41 `.bas` files in `deps/basicinterpret/tests/` produce **byte-identical** output from both implementations, including error messages and the trailing blank line after one. Those files are driven in place as individual CTest cases rather than copied, so the corpus cannot drift from upstream.
|
||||
|
||||
## Case Sensitivity
|
||||
|
||||
The old computers BASIC was originally written on only had CAPITAL LETTER KEYS on their keyboards. Modern keyboards have the indescribable luxury of upper and lower case. In this basic, verbs and function names are case insensitive. Variable names are case sensitive.
|
||||
|
||||
## Variables
|
||||
|
||||
* `A#` Integer variables
|
||||
* `A%` Float variables
|
||||
* `A$` String variables. Strings support addition operations with other types.
|
||||
* `LET` is supported but optional
|
||||
* Variables are strongly typed
|
||||
|
||||
Note that `%` means *float* here, which inverts the Commodore convention where `%` is integer. That is what the Go version does and it is not being changed out from under anybody.
|
||||
|
||||
## Arrays
|
||||
|
||||
* `DIM IDENTIFIER(DIMENSION[, ...])` allows for provisioning of multiple dimensional arrays
|
||||
* `DIM A$(3)` results in a single dimensional array of strings with 3 elements
|
||||
* `PRINT A$(2)` accesses the last element in an array and returns it to the verb
|
||||
* `LEN(A#)` on an array returns its total element count
|
||||
* Arrays are strongly typed
|
||||
|
||||
## Expressions
|
||||
|
||||
* `+`
|
||||
* `-`
|
||||
* `^`
|
||||
* `*` (also works on strings)
|
||||
* `/`
|
||||
* `< <= <> == >= >` less than, less than equal, not equal, equal, greater equal, greater than
|
||||
|
||||
Expressions can be grouped with `()` arbitrarily deeply. Currently the interpreter has a limit of 32 tokens and leaves per line. In effect this means about 16 operations in a single line.
|
||||
|
||||
## Commands (Verbs)
|
||||
|
||||
The following commands/verbs are implemented:
|
||||
|
||||
* `AUTO n` : Turn automatic line numbering on/off at increments of `n`
|
||||
* `REM` : everything after this is a comment
|
||||
* `DATA LITERAL[, ...]`: Define a series of literal values that can be read by a preceding `READ` verb
|
||||
* `DEF FN(X, ...) = expression` : Define a function with arguments that performs a given expression. See also "Subroutines", below.
|
||||
* `DELETE [n-n]`: Delete some portion of the lines in the current program
|
||||
* `DELETE`: Delete ALL lines in the program
|
||||
* `DELETE n-n`: Delete lines between `n` and `n` (inclusive)
|
||||
* `DELETE -n`: Delete lines from 0 to `n`
|
||||
* `DELETE n`: Delete lines from `n` to the end of the program
|
||||
* `DLOAD FILENAME`: Load the BASIC program in the file FILENAME (string literal or string variable) into memory
|
||||
* `DSAVE FILENAME`: Save the current BASIC program in memory to the file specified by FILENAME (string literal or string variable)
|
||||
* `EXIT`: Exit a loop before it would normally finish
|
||||
* `FOR` : Iterate over a range of values and perform (statement) or block each time.
|
||||
|
||||
```
|
||||
10 FOR I# = 1 TO 5
|
||||
20 REM Do some stuff in here
|
||||
30 NEXT I#
|
||||
|
||||
10 FOR I# = 1 TO 5 STEP 2
|
||||
20 REM Do some stuff here
|
||||
30 NEXT I#
|
||||
```
|
||||
|
||||
* `GOTO n`: Go to line n in the program
|
||||
* `GOSUB n`: Go to line n in the program and return here when `RETURN` is found
|
||||
* `IF (comparison) THEN (statement) [ELSE (statement)]` : Conditional branching
|
||||
* `INPUT "PROMPT STRING" VARIABLE`: Read input from the user and store it in the named variable
|
||||
* `LABEL IDENTIFIER`: Place a label at the current line number. Labels are constant integer identifiers that can be used in expressions like variables (including GOTO) but which cannot be assigned to. Labels do not have a type suffix (`$`, `#` or `%`).
|
||||
* `LIST [n-n]`: List all or a portion of the lines in the current program, with the same range forms as `DELETE`
|
||||
* `POKE ADDRESS, VALUE`: Poke the single byte VALUE into the ADDRESS
|
||||
* `PRINT (expression)`
|
||||
* `QUIT` : Exit the interpreter
|
||||
* `READ IDENTIFIER[, ...]` : Fill the named variables with data from a subsequent DATA statement
|
||||
* `RETURN` : return from `GOSUB` to the point where it was called
|
||||
* `RUN [n]`: Run the program currently in memory, optionally starting at line `n`
|
||||
* `STOP`: Stop program execution at the current point
|
||||
|
||||
## Functions
|
||||
|
||||
The following functions are implemented
|
||||
|
||||
* `ABS(x#|x%)`: Return the absolute value of the float or integer argument
|
||||
* `ATN(x#|x%)`: Return the arctangent of the float or integer argument. Input and output are in radians.
|
||||
* `CHR(x#)`: Return the character value of the UTF-8 unicode codepoint in x#. Returns as a string.
|
||||
* `COS(x#|x%)`: Return the cosine of the float or integer argument. Input and output are in radians.
|
||||
* `HEX(x#)`: Return the string representation of the integer number in x#
|
||||
* `INSTR(X$, Y$)`: Return the index of `Y$` within `X$` (-1 if not present)
|
||||
* `LEN(var$)`: Return the length of the object `var$` (either a string or an array)
|
||||
* `LEFT(X$, Y#)`: Return the leftmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
|
||||
* `LOG(X#|X%)`: Return the natural logarithm of X#|X%
|
||||
* `MID(var$, start, length)` : Return a substring from `var$`
|
||||
* `MOD(x#, y#)`: Return the modulus of ( x / y). Only works on integers.
|
||||
* `PEEK(X)`: Return the value of the BYTE at the memory location of integer X and return it as an integer
|
||||
* `POINTER(X)`: Return the address in memory for the value of the variable identified in X. This is the direct integer, float or string value stored, it is not a reference to the internal variable structure.
|
||||
* `POINTERVAR(X)` : Return the address in memory of the variable X. This is the address of the internal `akbasic_Variable` structure, which includes additional metadata about the variable, in addition to the value. For a pointer directly to the value, use `POINTER`.
|
||||
* `RAD(X#|X%)`: Convert degrees to radians
|
||||
* `RIGHT(X$, Y#)`: Return the rightmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
|
||||
* `SGN(X#)`: Returns the sign of X# (-1 for negative, 1 for positive, 0 if 0).
|
||||
* `SHL(X#, Y#)`: Returns the value of X# shifted left Y# bits
|
||||
* `SHR(X#, Y#)`: Returns the value of X# shifted right Y# bits
|
||||
* `SIN(X#|X%)`: Returns the sine of the float or integer argument. Input and output are radians.
|
||||
* `SPC(X#)`: Returns a string of X# spaces. This is included for compatibility, you can also use `(" " * X)` to multiply strings.
|
||||
* `STR(X#)`: Returns the string representation of X.
|
||||
* `TAN(X#|X%)`: Returns the tangent of the float or integer variable X. Input and output are in radians.
|
||||
* `VAL(X$)`: Returns the float value of the number in X$
|
||||
* `XOR(X#, Y#)`: Performs a bitwise exclusive OR on the two integer arguments
|
||||
|
||||
Unlike the Go version, none of these are bootstrapped by running a BASIC program of `DEF` statements through the interpreter at startup. A builtin's name, arity and handler are one row in the dispatch table in `src/verbs.c`, which means the interpreter no longer has to be running before the interpreter is ready.
|
||||
|
||||
## Subroutines
|
||||
|
||||
In addition to `DEF`, `GOTO` and `GOSUB`, this BASIC also implements subroutines that accept arguments, return a value, and can be called as functions. Example
|
||||
|
||||
```
|
||||
10 DEF ADDTWO(A#, B#)
|
||||
20 C# = A# + B#
|
||||
30 RETURN C#
|
||||
40 D# = ADDTWO(3, 5)
|
||||
50 PRINT D#
|
||||
```
|
||||
|
||||
Subroutines must be defined before they are called. Subroutines share the global variable scope with the rest of the program.
|
||||
|
||||
# Embedding the interpreter
|
||||
|
||||
The whole point of the rewrite. `libakbasic` is a static library with a thin `src/main.c` driver on top; the REPL, argv handling and `QUIT` belong to the driver, not to the library. A host program links the library and keeps control.
|
||||
|
||||
Four rules the library holds to, because a game engine cannot tolerate a scripting language that breaks any of them:
|
||||
|
||||
* **Nothing terminates the process.** No `exit()`, no `abort()`, no `panic`. Errors come back as `akerr_ErrorContext *`. `FINISH_NORETURN` never appears in the library at all — only in a `main()`, which today means the driver's and the embedding example's.
|
||||
* **Nothing calls `malloc`.** Every object comes from a fixed pool inside `akbasic_Runtime`. Exhausting one is a diagnosable error, not a crash and not a slow leak.
|
||||
* **No file-scope mutable state.** Interpreter state lives in an `akbasic_Runtime` you own. Two of them in one process do not interfere.
|
||||
* **The host owns the loop.** `akbasic_runtime_run(rt, n)` executes at most `n` source lines and returns. A script with an infinite loop costs you `n` lines per frame and nothing else.
|
||||
|
||||
A complete, compiled, runnable example is in [`examples/embed.c`](examples/embed.c) — it is built by every build and registered as a test, so it cannot rot. The shape is:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/*
|
||||
* The runtime carries every pool the interpreter owns -- a few megabytes -- so
|
||||
* it goes in static storage or inside your own game state, never on the stack.
|
||||
*/
|
||||
static akbasic_Runtime SCRIPT;
|
||||
|
||||
static const char *PROGRAM =
|
||||
"10 PRINT \"COUNTING:\"\n"
|
||||
"20 FOR I# = 1 TO 5\n"
|
||||
"30 PRINT I# * I#\n"
|
||||
"40 NEXT I#\n";
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *script_start(akbasic_TextSink *sink)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akbasic_runtime_init(&SCRIPT, sink));
|
||||
PASS(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
|
||||
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Call this once per frame. Eight source lines, then back to your renderer. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *script_tick(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( SCRIPT.mode == AKBASIC_MODE_QUIT ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
PASS(errctx, akbasic_runtime_run(&SCRIPT, 8));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## Where the output goes
|
||||
|
||||
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`:
|
||||
|
||||
```c
|
||||
typedef struct akbasic_TextSink
|
||||
{
|
||||
void *self;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len, bool *eof);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
|
||||
} akbasic_TextSink;
|
||||
```
|
||||
|
||||
`akbasic_sink_init_stdio()` ships with the library and is what the driver and the golden-file suite use. A game supplies its own and draws into a text layer. The interpreter never owns a window, a renderer or an event loop, and `readline` is expected to set `*eof` rather than block — that is how `INPUT` behaves sanely inside a frame.
|
||||
|
||||
## Error codes
|
||||
|
||||
The library reserves status values **512–767** with `libakerror`'s registry, under the owner string `"akbasic"`. `akbasic_runtime_init()` claims the range for you and is idempotent, so calling it twice is harmless and calling it after your own initialization is fine. If something else in the process already owns part of that band, `init` fails loudly rather than silently aliasing your error codes onto somebody else's.
|
||||
|
||||
For reference, the coordinated map across this dependency stack is `libakerror` 0–255, `libakgl` 256–260, and `akbasic` 512–767.
|
||||
|
||||
## Linking
|
||||
|
||||
```cmake
|
||||
add_subdirectory(deps/akbasic EXCLUDE_FROM_ALL)
|
||||
target_link_libraries(YOUR_GAME PRIVATE akbasic::akbasic)
|
||||
```
|
||||
|
||||
`akbasic` links `akerror::akerror` and `akstdlib::akstdlib` publicly, so you inherit both. If your project already declares those targets, declare them *before* adding this one — the same rule that applies to `libakgl`.
|
||||
|
||||
`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds an additional `akbasic_akgl` target carrying the akgl-backed text sink; it is off by default, which is why the interpreter and its whole test suite build on a machine with no SDL.
|
||||
|
||||
## Limits
|
||||
|
||||
Everything is bounded and pre-declared. The numbers are in `include/akbasic/types.h` and are the Go version's, plus three the Go version did not need because it called `make()`:
|
||||
|
||||
| Constant | Value | What it bounds |
|
||||
|---|---|---|
|
||||
| `AKBASIC_MAX_LEAVES` / `_TOKENS` | 32 | AST nodes and tokens per source line (~16 operations) |
|
||||
| `AKBASIC_MAX_VALUES` | 64 | Intermediate values per line |
|
||||
| `AKBASIC_MAX_VARIABLES` | 128 | Variables per scope |
|
||||
| `AKBASIC_MAX_SOURCE_LINES` | 9999 | Program length |
|
||||
| `AKBASIC_MAX_LINE_LENGTH` | 256 | Characters per line |
|
||||
| `AKBASIC_MAX_STRING_LENGTH` | 256 | Characters in a string value |
|
||||
| `AKBASIC_MAX_ENVIRONMENTS` | 32 | Nesting depth of `FOR` and `GOSUB` |
|
||||
| `AKBASIC_MAX_ARRAY_ELEMENTS` | 1024 | Elements in one array |
|
||||
| `AKBASIC_MAX_ARRAY_VALUES` | 4096 | Array elements across all variables |
|
||||
|
||||
Raising any of them costs BSS and nothing else. An `akbasic_Runtime` is presently **10.1MB**, and it is worth knowing where that goes before you reach for a knob: 4.1MB is the environment pool, 2.5MB the 9999-line source table, 2.3MB the function-definition pool and 1.2MB the array value pool. `AKBASIC_MAX_ENVIRONMENTS` is the expensive one — each environment carries its own token, leaf and value arrays — so halving it to 16 saves twice what halving the source table does.
|
||||
|
||||
# What Isn't Implemented / Isn't Working
|
||||
|
||||
## Defects inherited from the Go version
|
||||
|
||||
These are reproduced deliberately, not fixed. The Go version's test corpus is the acceptance suite, so a silent correction here is a behaviour change that would show up as a failing golden file. Each is catalogued in [`TODO.md`](TODO.md) section 6 with the file and line, and `tests/known_reference_defects.c` asserts the **correct** contract for six of them under `AKBASIC_KNOWN_FAILING_TESTS` — when one is fixed, CTest reports "unexpectedly passed", which is the cue to move it.
|
||||
|
||||
Five of them the port found; nobody knew about these before:
|
||||
|
||||
* **`1 - 2 - 3` computes `1 - 2`.** Subtraction stops after one operator where addition loops, so the rest of the line is abandoned in the token stream. This is the bad one — it is a *wrong answer*, not a refused one. `2 ^ 3 ^ 2` has the same shape.
|
||||
* **A negative literal cannot be passed to a builtin.** `ABS(-9)` is rejected with "function ABS takes 1 arguments, received 2", because the arity counter walks the same `.right` pointer a unary-minus leaf keeps its operand on. Assign to a variable first, as the Go version's own `sgn.bas` test quietly does.
|
||||
* **A comparison operator in a line's final column is dropped.** `A# =` produces one token, not two, because the scanner cannot peek past the end of the line and gives up without recording the operator.
|
||||
* **Hex literals do not work.** `0xff` lexes as `0x` followed by an identifier `ff`. The base-16 branch in the literal parser is unreachable, so the hex support the Go README implies has never existed.
|
||||
* **`PRINT$` is accepted as a variable name.** The "Reserved word in variable name" check compares the lexeme *including* its type suffix against the keyword table, so it never matches and never fires.
|
||||
|
||||
Plus six more the original already carried: a leading `0` selects base 8, so `PRINT 010` prints 8 and `PRINT 08` will not parse; `setBoolean` tags the value it builds `TYPE_STRING`; `stopWaiting` ignores its argument, so an inner block can clear an outer block's pending wait; `toString` on a variable has its emptiness test inverted; `EXIT` pops a loop without clearing that wait; and `mathPlus` mutates its left operand in place where every other operator clones. That last one is load-bearing rather than merely wrong — `NEXT` relies on it to advance the loop counter, so it cannot be fixed on its own.
|
||||
|
||||
## Not implemented
|
||||
|
||||
* Multiple statements on one line (e.g. `10 PRINT A$ : REM This prints the thing`). The `COLON` token exists and nothing consumes it.
|
||||
* Using an array reference inside a parameter list (e.g. `READ A$(0), B#`) results in parsing errors
|
||||
* `APPEND`, `BACKUP`, `BEGIN`, `BEND`, `BLOAD`, `BOOT`, `BOX`, `BSAVE`
|
||||
* `CATALOG`, `CHAR`, `CIRCLE`, `CLOSE`, `CLR`, `CMD`, `COLLECT`, `COLLISION`, `COLOR`, `CONCAT`, `CONT`, `COPY`
|
||||
* `DCLEAR`, `DCLOSE`, `DIRECTORY`, `DOPEN`, `DRAW`, `DVERIFY`
|
||||
* `DO`, `LOOP`, `WHILE`, `UNTIL`. You can do the same thing with `IF` and `GOTO`.
|
||||
* `END`, `ENVELOPE`, `ER`, `ERR`
|
||||
* `FETCH`, `FILTER`
|
||||
* `GET`, `GETKEY`, `GRAPHIC`, `GSHAPE`
|
||||
* `HEADER`, `HELP`
|
||||
* `KEY`, `LOAD`, `LOCATE`
|
||||
* `MOVSPR`, `NEW`, `ON`, `PAINT`, `PLAY`, `PUDEF`
|
||||
* `RENAME`, `RENUMBER`, `RESTORE`, `RESUME`
|
||||
* `SAVE`, `SCALE`, `SCNCLR`, `SCRATCH`, `SLEEP`, `SOUND`
|
||||
* `SPRCOLOR`, `SPRDEF`, `SPRITE`, `SPRSAV`, `SSHAPE`, `STASH`, `SWAP`, `SYS`
|
||||
* `TEMPO`, `TI`, `TRAP`, `TROFF`, `TRON`
|
||||
* `USING`, `VERIFY`, `VOL`, `WAIT`, `WIDTH`, `WINDOW`
|
||||
* The I/O-channel variants (`GETIO`, `INPUTIO`, `OPENIO`, `PRINTIO`, `RECORDIO`)
|
||||
|
||||
Four of those are blocked on capabilities `libakgl` does not have yet — text measurement, immediate-mode drawing, audio, and a non-blocking keystroke read. Rather than work around them here, they are filed in [`deps/libakgl/TODO.md`](deps/libakgl/TODO.md) under "API gaps blocking akbasic", with the entry point each one wants.
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
* `BANK` - the modern PC memory layout is incompatible with the idea of bank switching
|
||||
* `FAST` - Irrelevant on modern PC CPUs
|
||||
* `MONITOR` - there is no machine-language monitor to drop into
|
||||
|
||||
# Dependencies
|
||||
|
||||
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error contexts. Every function that can fail returns one.
|
||||
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.1.0 — libc wrappers that report through `libakerror`.
|
||||
* [libakgl](https://source.starfort.tech/andrew/libakgl) 0.1.0 — **optional**, only for `-DAKBASIC_WITH_AKGL=ON`. Pulls in SDL3.
|
||||
* [basicinterpret](https://source.starfort.tech/andrew/basicinterpret) — the Go original, vendored as the behavioural spec and the acceptance corpus. Not linked, not built.
|
||||
|
||||
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is nothing to install first.
|
||||
|
||||
Note that `libakstdlib`'s `aksl_atoi`/`atol`/`atoll`/`atof` family is deliberately **not** used here, because it cannot report a conversion failure — `atoi("not a number")` returns success with `0`. `src/convert.c` wraps `strtoll`/`strtod` with the strict contract instead, and `TODO.md` section 1.9 records which of that library's calls are cleared for use. That file will be deleted when `libakstdlib` grows the wrappers its own `TODO.md` section 3.1 already calls for.
|
||||
12
TODO.md
12
TODO.md
@@ -342,6 +342,14 @@ cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # 92.3% line, 96.9% function
|
||||
| Verbs and functions | `src/runtime_commands.c`, `src/runtime_functions.c` | `basicruntime_{commands,functions}.go` |
|
||||
| Text sink | `src/sink_stdio.c` | `basicruntime_graphics.go`'s stdout mirror |
|
||||
| Driver | `src/main.c` | `main.go` |
|
||||
| Embedding example | `examples/embed.c` | *(new)* |
|
||||
|
||||
`akbasic_runtime_load(rt, source)` was added while writing the README's embedding
|
||||
section: a host usually holds its script as a string and wants the sink reserved for output,
|
||||
and the only path that existed — `AKBASIC_MODE_RUNSTREAM` reading through the sink's
|
||||
`readline` — forces a game to point its output device at its source text. `examples/embed.c`
|
||||
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 reference's own corpus, driven in place.** All 41 `.bas` files
|
||||
under `deps/basicinterpret/tests/` are registered as individual CTest cases and byte-compared
|
||||
@@ -571,9 +579,9 @@ entire test corpus and passes clean under ASan and UBSan.
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `ctest` | 59/59 — 41 golden cases, 17 unit tests, 1 known-failing |
|
||||
| `ctest` | 60/60 — 41 golden cases, 17 unit tests, 1 embedding example, 1 known-failing |
|
||||
| Golden corpus | 41/41 byte-exact, driven in place from the submodule |
|
||||
| ASan + UBSan | 59/59 |
|
||||
| ASan + UBSan | 60/60 |
|
||||
| Line coverage | 92.3% (2823/3058) — above the 90% gate |
|
||||
| Function coverage | 96.9% (221/228) |
|
||||
| Warnings | none under `-Wall -Wextra` |
|
||||
|
||||
185
examples/embed.c
Normal file
185
examples/embed.c
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @file embed.c
|
||||
* @brief Embedding akbasic in a host program: the whole surface, in one file.
|
||||
*
|
||||
* This is the code in README.md's "Embedding the interpreter" section, kept here
|
||||
* so it is compiled by every build rather than rotting in a document. Build it
|
||||
* with -DAKBASIC_BUILD_EXAMPLES=ON and run `./build/examples/embed`.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akbasic/error.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/*
|
||||
* The runtime carries every pool the interpreter owns -- a few megabytes -- so it
|
||||
* goes in static storage or inside the host's own state, never on the stack.
|
||||
*/
|
||||
static akbasic_Runtime SCRIPT;
|
||||
|
||||
/* ----------------------------------------------------------- a custom sink -- */
|
||||
|
||||
/*
|
||||
* A sink is a record of function pointers plus whatever state the host needs.
|
||||
* This one collects output into a fixed buffer, which is what a game would do
|
||||
* before blitting it to a text layer. A real one would draw through the akgl
|
||||
* renderer the host already initialized.
|
||||
*/
|
||||
typedef struct
|
||||
{
|
||||
char buffer[4096];
|
||||
size_t used;
|
||||
} ConsoleState;
|
||||
|
||||
static ConsoleState CONSOLE;
|
||||
static akbasic_TextSink CONSOLE_SINK;
|
||||
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *console_write(akbasic_TextSink *self, const char *text)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
ConsoleState *state = NULL;
|
||||
size_t length = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, (self != NULL && text != NULL), AKERR_NULLPOINTER,
|
||||
"NULL argument in console write");
|
||||
state = (ConsoleState *)self->self;
|
||||
length = strlen(text);
|
||||
FAIL_ZERO_RETURN(errctx, (length < sizeof(state->buffer) - state->used),
|
||||
AKBASIC_ERR_BOUNDS, "console buffer is full");
|
||||
memcpy(state->buffer + state->used, text, length);
|
||||
state->used += length;
|
||||
state->buffer[state->used] = '\0';
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *console_writeln(akbasic_TextSink *self, const char *text)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, console_write(self, text));
|
||||
PASS(errctx, console_write(self, "\n"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *console_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER,
|
||||
"NULL argument in console readline");
|
||||
/* No keyboard wired up: INPUT sees end-of-stream rather than blocking. */
|
||||
(void)len;
|
||||
dest[0] = '\0';
|
||||
*eof = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *console_clear(akbasic_TextSink *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
ConsoleState *state = NULL;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, (self != NULL), AKERR_NULLPOINTER, "NULL sink in console clear");
|
||||
state = (ConsoleState *)self->self;
|
||||
state->used = 0;
|
||||
state->buffer[0] = '\0';
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *console_sink_init(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
CONSOLE.used = 0;
|
||||
CONSOLE.buffer[0] = '\0';
|
||||
CONSOLE_SINK.self = &CONSOLE;
|
||||
CONSOLE_SINK.write = console_write;
|
||||
CONSOLE_SINK.writeln = console_writeln;
|
||||
CONSOLE_SINK.readline = console_readline;
|
||||
CONSOLE_SINK.clear = console_clear;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the driver -- */
|
||||
|
||||
static const char *PROGRAM =
|
||||
"10 PRINT \"COUNTING:\"\n"
|
||||
"20 FOR I# = 1 TO 5\n"
|
||||
"30 PRINT I# * I#\n"
|
||||
"40 NEXT I#\n"
|
||||
"50 PRINT \"DONE\"\n";
|
||||
|
||||
/*
|
||||
* One frame's worth of script. The interpreter never surrenders control: it runs
|
||||
* at most `budget` source lines and returns, so a host game keeps its frame rate
|
||||
* no matter what the script does -- including an infinite loop.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *step_script(int budget)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( SCRIPT.mode == AKBASIC_MODE_QUIT ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
PASS(errctx, akbasic_runtime_run(&SCRIPT, budget));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/*
|
||||
* The host's game loop. A loop that calls an error-returning function has to be
|
||||
* its own function using PASS: CATCH expands to a C `break`, so inside a loop
|
||||
* within an ATTEMPT it would leave the loop rather than the ATTEMPT and let the
|
||||
* rest of the block run with an error pending.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *game_loop(int frames, int budget, int *ran)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
for ( *ran = 0; *ran < frames && SCRIPT.mode != AKBASIC_MODE_QUIT; (*ran)++ ) {
|
||||
PASS(errctx, step_script(budget));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int frames = 0;
|
||||
int rc = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, console_sink_init());
|
||||
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &CONSOLE_SINK));
|
||||
CATCH(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
|
||||
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
|
||||
/*
|
||||
* Ten frames at two source lines each. The budget is artificially mean to
|
||||
* make the point visible: the script gets partway through and stops, and
|
||||
* the host stayed in control the whole time.
|
||||
*/
|
||||
CATCH(errctx, game_loop(10, 2, &frames));
|
||||
printf("--- after %d frames of 2 lines each ---\n%s\n", frames, CONSOLE.buffer);
|
||||
|
||||
/*
|
||||
* Then let it finish. A budget of 0 is unbounded -- fine for a tool, not
|
||||
* for a game. Note the interpreter only reaches MODE_QUIT after walking to
|
||||
* the end of the line table, which is AKBASIC_MAX_SOURCE_LINES steps and
|
||||
* is what the reference does too.
|
||||
*/
|
||||
CATCH(errctx, game_loop(AKBASIC_MAX_SOURCE_LINES, 8, &frames));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the embedded script failed");
|
||||
rc = 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
printf("--- run to completion ---\n%s", CONSOLE.buffer);
|
||||
return rc;
|
||||
}
|
||||
@@ -149,6 +149,28 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int
|
||||
/** @brief Point the runtime at an input stream and choose a starting mode. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_start(akbasic_Runtime *obj, int mode);
|
||||
|
||||
/**
|
||||
* @brief Load a whole program from memory, filing each line under its line number.
|
||||
*
|
||||
* The entry point for an embedding host, which usually already holds its script
|
||||
* as a string and wants the sink reserved for output. The alternative --
|
||||
* AKBASIC_MODE_RUNSTREAM -- reads the program through the sink's readline, which
|
||||
* works for a file-backed driver but forces a game to point its output device at
|
||||
* its source text.
|
||||
*
|
||||
* Lines are separated by `\n`; a `\r` before it is tolerated. Blank lines are
|
||||
* skipped. A line with no line number is filed under the last one seen, matching
|
||||
* what the scanner does in RUNSTREAM mode. This does not run anything: follow it
|
||||
* with akbasic_runtime_start(obj, AKBASIC_MODE_RUN).
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
* @param source Whole program text; must not be NULL.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When `obj` or `source` is NULL.
|
||||
* @throws AKBASIC_ERR_BOUNDS When a line is longer than AKBASIC_MAX_LINE_LENGTH or its number is out of range.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_load(akbasic_Runtime *obj, const char *source);
|
||||
|
||||
/* --- Internal API: exposed for the scanner, parser and verb handlers only. --- */
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_new_variable(akbasic_Runtime *obj, akbasic_Variable **dest);
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @brief The standalone driver.
|
||||
*
|
||||
* Everything that belongs to a program rather than to a library lives here: argv
|
||||
* handling, sink selection, the unbounded run loop, and the one FINISH_NORETURN
|
||||
* in the tree. The interpreter library itself never terminates the process.
|
||||
* handling, sink selection, the unbounded run loop, and a FINISH_NORETURN --
|
||||
* which belongs only in a main(). The interpreter library itself never
|
||||
* terminates the process.
|
||||
*
|
||||
* The runtime is static rather than automatic because it carries every pool the
|
||||
* interpreter owns -- several megabytes -- and that will not fit on a default
|
||||
|
||||
@@ -741,6 +741,49 @@ akerr_ErrorContext *akbasic_runtime_start(akbasic_Runtime *obj, int mode)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akbasic_runtime_load(akbasic_Runtime *obj, const char *source)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
char line[AKBASIC_MAX_LINE_LENGTH];
|
||||
char scanned[AKBASIC_MAX_LINE_LENGTH];
|
||||
const char *cursor = NULL;
|
||||
const char *eol = NULL;
|
||||
size_t length = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in load");
|
||||
FAIL_ZERO_RETURN(errctx, (source != NULL), AKERR_NULLPOINTER, "NULL source in load");
|
||||
|
||||
for ( cursor = source; *cursor != '\0'; cursor = (*eol == '\0' ? eol : eol + 1) ) {
|
||||
eol = strchr(cursor, '\n');
|
||||
if ( eol == NULL ) {
|
||||
eol = cursor + strlen(cursor);
|
||||
}
|
||||
length = (size_t)(eol - cursor);
|
||||
if ( length > 0 && cursor[length - 1] == '\r' ) {
|
||||
length -= 1;
|
||||
}
|
||||
FAIL_ZERO_RETURN(errctx, (length < sizeof(line)), AKBASIC_ERR_BOUNDS,
|
||||
"Source line of %zu characters exceeds the %d character limit",
|
||||
length, AKBASIC_MAX_LINE_LENGTH - 1);
|
||||
memcpy(line, cursor, length);
|
||||
line[length] = '\0';
|
||||
if ( line[0] == '\0' ) {
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* Scanning is what picks the line number off the front and rewrites the
|
||||
* line to what follows it -- the same path RUNSTREAM takes, so a program
|
||||
* loaded from memory and one read from a file are filed identically.
|
||||
*/
|
||||
PASS(errctx, akbasic_runtime_zero(obj));
|
||||
PASS(errctx, akbasic_scanner_zero(obj));
|
||||
PASS(errctx, akbasic_scanner_scan(obj, line, scanned, sizeof(scanned)));
|
||||
PASS(errctx, akbasic_runtime_store_line(obj, obj->environment->lineno, scanned));
|
||||
}
|
||||
obj->environment->nextline = 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
Reference in New Issue
Block a user