Draw: implement the BASIC 7.0 graphics verbs

GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:17:48 -04:00
parent 9b9dd09c5a
commit 9d99d0a67e
15 changed files with 1264 additions and 0 deletions

View File

@@ -107,6 +107,7 @@ set(AKBASIC_SOURCES
src/runtime.c
src/runtime_commands.c
src/runtime_functions.c
src/runtime_graphics.c
src/scanner.c
src/sink_stdio.c
src/symtab.c
@@ -191,6 +192,7 @@ set(AKBASIC_TESTS
environment_scope
error_codes
grammar_leaves
graphics_verbs
parser_commands
parser_expressions
runtime_evaluate
@@ -269,6 +271,41 @@ if(AKBASIC_GOLDEN_CASES)
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
endif()
# The local golden corpus, for verbs the reference never implemented.
#
# It has to be separate: the corpus above is a submodule and nothing in this
# repository may add files to it, but goal 2's new verbs still need the .bas/.txt
# half of their coverage. Registered under local_ so a failure says at a glance
# which corpus it came from.
#
# 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
# 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
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/language/*.bas"
)
set(AKBASIC_LOCAL_NAMES)
foreach(_case IN LISTS AKBASIC_LOCAL_CASES)
string(REGEX REPLACE "^language/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}")
_add_test(
NAME local_${_name}
COMMAND ${CMAKE_COMMAND}
-DBASIC=$<TARGET_FILE:basic>
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/${_case}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
)
list(APPEND AKBASIC_LOCAL_NAMES local_${_name})
endforeach()
if(AKBASIC_LOCAL_NAMES)
_set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES TIMEOUT 30)
endif()
# ---------------------------------------------------------------------------
# Mutation testing.
#

45
TODO.md
View File

@@ -478,6 +478,51 @@ and each one has to be defensible against the golden suite.
leaves the parent waiting for a `NEXT` that never arrives (§6 item 8) — in C that is a hang
rather than a misbehaviour, and no golden case depends on it.
### Deviations in the verbs the reference never implemented
Items 112 above are deviations *from ported code*. These are deviations from **Commodore
BASIC 7.0**, in verbs the reference lists as unimplemented and which therefore had no Go
behaviour to port. They matter to somebody typing in a listing out of a C128 manual.
13. **Hardware verbs reach a device through a backend record, and refuse when there is none.**
`akbasic_GraphicsBackend`, `_AudioBackend` and `_InputBackend` are records of function
pointers on the runtime; all three may be `NULL`, which is what the standalone driver
gives them. A verb that needs a device it was not given raises `AKBASIC_ERR_DEVICE` naming
itself. `COLOR`, `LOCATE`, `SCALE`, `ENVELOPE`, `TEMPO` and `VOL` deliberately do *not*
require one — they change interpreter state, so a program can configure itself before the
host lends it a renderer.
14. **`CIRCLE` is a polygon, always.** BASIC 7.0's `CIRCLE` takes two radii, a start and end
angle, a rotation and a degree increment, which makes it an inc-degree polygon by
definition. `akgl_draw_circle` exists and is deliberately **not** used: it draws one
radius, full sweep, unrotated, so it could serve only the case where every optional
argument is defaulted — and a shape that changed character depending on whether the two
radii happened to be equal is worse than one that is uniformly a polygon.
15. **`SSHAPE` stores a handle in the string, not the pixels.** A real C128 packs the
region's bitmap into the string variable. Here a value's string is a fixed 256 bytes
(§1.2) and a saved region is a device surface, so the variable gets `SHAPE:<n>` and the
surface stays in a fixed pool on the backend. `GSHAPE` accepts only a string carrying that
prefix, so a hand-written one is refused rather than pasting an unrelated slot. **What
this costs:** a shape cannot be written to disk, cannot be concatenated, and `LEN` of it
is not its size. Nothing in BASIC does any of those to a shape string except a program
deliberately poking at it.
16. **`BOX` fills on a negative angle rather than on a seventh argument.** 7.0's last
argument selects outline or fill and sits *after* the rotation, which would make a filled
box a seven-argument call. That is filed rather than implemented; today a rotation of zero
outlines through the renderer's rectangle and any other rotation draws four lines.
17. **`GRAPHIC` records its mode but honours only one consequence of it.** 7.0's five modes
differ in bitmap resolution and in whether the bottom of the screen stays text. Neither
means anything against a host's renderer, whose size the interpreter does not know and
does not own. The mode is stored, an out-of-range one is refused because that is a typo
worth catching, and the one observable rule is that mode 0 is text.
18. **Coordinates reaching a backend are BASIC's 320x200 space, and scaling to the window is
the host's job.** The interpreter cannot ask the renderer how big it is without owning it.
`SCALE` maps user coordinates onto that space; with `SCALE` off they pass through.
---
## 6. Reference defects — ported faithfully, fix them deliberately

View File

@@ -23,6 +23,20 @@
#include <akbasic/types.h>
/**
* @brief The coordinate space the backend receives, in C128 hi-res pixels.
*
* The interpreter does not own the renderer and cannot ask how big it is, so it
* emits the coordinates the program wrote -- BASIC 7.0's 320x200 hi-res screen,
* or whatever SCALE maps onto it. Stretching that to the window is the host's
* business, the same way the host owns the window in the first place.
*/
#define AKBASIC_GRAPHICS_WIDTH 320
#define AKBASIC_GRAPHICS_HEIGHT 200
/** @brief How many COLOR sources there are. BASIC 7.0 numbers them 0 through 6. */
#define AKBASIC_COLOR_SOURCES 7
/**
* @brief An RGBA color, in the shape SDL_Color has without requiring SDL.
*
@@ -38,6 +52,25 @@ typedef struct
uint8_t a;
} akbasic_Color;
/**
* @brief The graphics verbs' own state, which lives on the runtime.
*
* DRAW, BOX, CIRCLE and PAINT name a *color source*, not a color: COLOR binds a
* source to a palette index and the drawing verbs reference the source. That
* indirection is BASIC 7.0's, not an invention here, and it is why `source` is a
* table of palette indices rather than a single current color.
*/
typedef struct
{
int mode; /* GRAPHIC mode; 0 is text, and refuses to draw */
int source[AKBASIC_COLOR_SOURCES]; /* palette index bound to each COLOR source */
double x; /* pixel cursor: where LOCATE put it, or where */
double y; /* the last DRAW ended */
bool scaling; /* SCALE on */
double xmax; /* user-coordinate maxima SCALE maps from */
double ymax;
} akbasic_GraphicsState;
/**
* @brief Where the graphics verbs draw.
*
@@ -102,4 +135,27 @@ typedef struct akbasic_GraphicsBackend
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_palette(int index, akbasic_Color *dest);
/**
* @brief Reset the graphics state to its power-on values.
*
* Text mode, the default color-source bindings, the pixel cursor at the origin
* and scaling off. GRAPHIC CLR and NEW both come back through here.
*
* @param obj Object to initialize, inspect, or modify.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_state_init(akbasic_GraphicsState *obj);
/**
* @brief Resolve a COLOR source to the RGBA the backend draws with.
* @param obj Graphics state to read the binding from.
* @param source COLOR source number, 0 through 6.
* @param dest Output destination populated by the function.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` or `dest` is NULL.
* @throws AKBASIC_ERR_BOUNDS When `source` is outside 0..6.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_source_color(akbasic_GraphicsState *obj, int source, akbasic_Color *dest);
#endif // _AKBASIC_GRAPHICS_H_

View File

@@ -96,6 +96,14 @@ typedef struct akbasic_Runtime
akbasic_AudioBackend *audio;
akbasic_InputBackend *input;
/*
* The graphics verbs' own state -- mode, color-source bindings, pixel cursor
* and SCALE. It lives here rather than on the backend because it is BASIC's
* state, not the device's: a host that swaps one renderer for another does
* not expect the program's COLOR settings to go with it.
*/
akbasic_GraphicsState gfx;
/*
* The host's clock, in milliseconds, as of its last akbasic_runtime_settime()
* call. The interpreter does not read a clock: it owns no loop and must not

View File

@@ -40,6 +40,25 @@ static const akbasic_Color PALETTE[17] = {
{ 0x9f, 0x9f, 0x9f, 0xff } /* 16 -- light grey */
};
/**
* @brief What each COLOR source is bound to before a program says otherwise.
*
* A C128 powers on with a blue screen and light blue text. That is not
* reproduced: a graphics screen defaulting to blue-on-blue makes an unset COLOR
* look like a bug in the interpreter, so the defaults here are the readable ones.
* The choice is worth naming rather than hiding, since it is the one place these
* tables are not a transcription.
*/
static const int SOURCE_DEFAULTS[AKBASIC_COLOR_SOURCES] = {
1, /* 0 -- 40-column background: black */
2, /* 1 -- 40-column foreground: white */
3, /* 2 -- multicolor 1: red */
4, /* 3 -- multicolor 2: cyan */
1, /* 4 -- 40-column border: black */
2, /* 5 -- character color: white */
1 /* 6 -- 80-column background: black */
};
akerr_ErrorContext *akbasic_graphics_palette(int index, akbasic_Color *dest)
{
PREPARE_ERROR(errctx);
@@ -51,3 +70,36 @@ akerr_ErrorContext *akbasic_graphics_palette(int index, akbasic_Color *dest)
*dest = PALETTE[index];
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_graphics_state_init(akbasic_GraphicsState *obj)
{
PREPARE_ERROR(errctx);
int source = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER,
"NULL graphics state in init");
obj->mode = 0;
obj->x = 0.0;
obj->y = 0.0;
obj->scaling = false;
obj->xmax = (double)AKBASIC_GRAPHICS_WIDTH;
obj->ymax = (double)AKBASIC_GRAPHICS_HEIGHT;
for ( source = 0; source < AKBASIC_COLOR_SOURCES; source++ ) {
obj->source[source] = SOURCE_DEFAULTS[source];
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_graphics_source_color(akbasic_GraphicsState *obj, int source, akbasic_Color *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in source_color");
FAIL_ZERO_RETURN(errctx, (source >= 0 && source < AKBASIC_COLOR_SOURCES),
AKBASIC_ERR_BOUNDS,
"Color source %d out of range (0 to %d)",
source, AKBASIC_COLOR_SOURCES - 1);
PASS(errctx, akbasic_graphics_palette(obj->source[source], dest));
SUCCEED_RETURN(errctx);
}

View File

@@ -20,6 +20,86 @@
#include "verbs.h"
akerr_ErrorContext *akbasic_parse_arglist(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_Token *operator_ = NULL;
/*
* The generic "this verb takes a comma-separated list" path, shared by the
* graphics and sound verbs. The default command path parses a *single*
* expression, which quietly turns COLOR 0, 1 into COLOR 0 with a stray 1 --
* so every verb taking more than one argument needs this rather than NULL.
*
* The verb name comes back out of the token stream rather than being passed
* in, because a parse handler is dispatched by name and the table has no
* column to carry one.
*/
PASS(errctx, akbasic_parser_previous(parser, &operator_));
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL), AKBASIC_ERR_SYNTAX,
"%s expected at least one argument", operator_->lexeme);
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_parse_draw(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arglist = NULL;
akbasic_ASTLeaf *expr = NULL;
akbasic_ASTLeaf *tail = NULL;
akbasic_Token *peeked = NULL;
akbasic_Token *operator_ = NULL;
/*
* DRAW source, x1,y1 TO x2,y2 TO x3,y3 -- a polyline, with TO between pairs
* instead of a comma. TO is a reserved word the argument list will not cross,
* so the pairs after the first are appended by hand and the whole thing
* flattens to one argument chain: source, x1, y1, x2, y2, ... The exec
* handler walks it in pairs and never has to know a TO was involved.
*/
PASS(errctx, akbasic_parser_previous(parser, &operator_));
PASS(errctx, akbasic_parser_argument_list(parser, AKBASIC_TOK_FUNCTION_ARGUMENT, false, &arglist));
FAIL_ZERO_RETURN(errctx, (arglist != NULL && arglist->right != NULL), AKBASIC_ERR_SYNTAX,
"DRAW expected a color source and a coordinate");
tail = arglist->right;
while ( tail->right != NULL ) {
tail = tail->right;
}
for ( ;; ) {
peeked = akbasic_parser_peek(parser);
if ( peeked == NULL ||
peeked->tokentype != AKBASIC_TOK_COMMAND ||
strcmp(peeked->lexeme, "TO") != 0 ) {
break;
}
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMAND),
AKBASIC_ERR_SYNTAX, "DRAW expected TO");
PASS(errctx, akbasic_parser_expression(parser, &tail->right));
FAIL_ZERO_RETURN(errctx, (tail->right != NULL), AKBASIC_ERR_SYNTAX,
"DRAW expected X after TO");
tail = tail->right;
FAIL_ZERO_RETURN(errctx, akbasic_parser_match1(parser, AKBASIC_TOK_COMMA),
AKBASIC_ERR_SYNTAX, "DRAW expected TO X,Y");
PASS(errctx, akbasic_parser_expression(parser, &tail->right));
FAIL_ZERO_RETURN(errctx, (tail->right != NULL), AKBASIC_ERR_SYNTAX,
"DRAW expected Y after TO X,");
tail = tail->right;
}
PASS(errctx, akbasic_parser_new_leaf(parser, &expr));
PASS(errctx, akbasic_leaf_new_command(expr, operator_->lexeme, arglist));
*dest = expr;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_parse_let(akbasic_Parser *parser, akbasic_ASTLeaf **dest)
{
PREPARE_ERROR(errctx);

View File

@@ -140,6 +140,7 @@ akerr_ErrorContext *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink
obj->run_finished_mode = AKBASIC_MODE_REPL;
obj->inputEof = false;
PASS(errctx, akbasic_graphics_state_init(&obj->gfx));
PASS(errctx, akbasic_valuepool_init(&obj->valuepool));
PASS(errctx, akbasic_value_zero(&obj->staticTrueValue));
PASS(errctx, akbasic_value_zero(&obj->staticFalseValue));

654
src/runtime_graphics.c Normal file
View File

@@ -0,0 +1,654 @@
/**
* @file runtime_graphics.c
* @brief The group G verb implementations: GRAPHIC, COLOR, DRAW, BOX, CIRCLE,
* PAINT, SCALE, SSHAPE, GSHAPE and LOCATE.
*
* Nothing here includes an SDL or a libakgl header. Every drawing call goes
* through the akbasic_GraphicsBackend record the host attached, which is what
* lets the whole group be tested in a build with no SDL on the machine.
*
* These verbs are not in the Go reference -- it lists all of them as
* unimplemented -- so the semantics come from Commodore BASIC 7.0 rather than
* from a port. Where 7.0 does something a modern renderer cannot, the deviation
* is recorded in TODO.md section 5 rather than papered over here.
*/
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akbasic/convert.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "verbs.h"
/* Most verbs answer "did something happen"; this is that answer. */
#define SUCCEED_TRUE(__obj, __dest) \
do { \
*(__dest) = &(__obj)->staticTrueValue; \
} while ( 0 )
/** @brief What SSHAPE writes into the string variable, ahead of the slot number. */
#define SHAPE_PREFIX "SHAPE:"
/** @brief CIRCLE's default arc increment, in degrees. BASIC 7.0 uses two. */
#define CIRCLE_DEFAULT_INC 2.0
/**
* @brief Refuse politely when the host lent us no graphics device.
*
* Every verb in this file opens with it. The standalone driver attaches no
* backend at all, so this is the common path rather than an edge case, and it
* has to name the verb -- "no graphics device" on its own tells a program author
* nothing about which line to look at.
*/
static akerr_ErrorContext *require_graphics(akbasic_Runtime *obj, const char *verb)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && verb != NULL), AKERR_NULLPOINTER,
"NULL argument in require_graphics");
FAIL_ZERO_RETURN(errctx, (obj->graphics != NULL), AKBASIC_ERR_DEVICE,
"%s needs a graphics device and this runtime has none", verb);
SUCCEED_RETURN(errctx);
}
/**
* @brief Collect a verb's arguments into an array, evaluating each one.
*
* The graphics verbs all take a short positional list with optional trailing
* members, so every handler wants the same thing: how many arguments arrived,
* and their numeric values. Strings are refused here rather than in each verb --
* SSHAPE and GSHAPE, which do take one, read their leaf directly instead.
*
* @param obj Runtime to evaluate against.
* @param expr The command leaf.
* @param verb Name to use in a diagnostic.
* @param values Where to put the evaluated arguments.
* @param max Capacity of `values`.
* @param count Output destination populated by the function.
*/
static akerr_ErrorContext *collect_numbers(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, double *values, int max, int *count)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
akbasic_Value *value = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && values != NULL && count != NULL),
AKERR_NULLPOINTER, "NULL argument in collect_numbers");
*count = 0;
arg = akbasic_leaf_first_argument(expr);
/*
* A loop, so CATCH and the _BREAK macros are banned in here -- they expand to
* a C break and would leave the loop with an error still pending. PASS and
* the _RETURN forms only.
*/
while ( arg != NULL ) {
FAIL_NONZERO_RETURN(errctx, (*count >= max), AKBASIC_ERR_SYNTAX,
"%s takes at most %d arguments", verb, max);
PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING),
AKBASIC_ERR_TYPE,
"%s expected a number in argument %d", verb, *count + 1);
if ( value->valuetype == AKBASIC_TYPE_FLOAT ) {
values[*count] = value->floatval;
} else {
values[*count] = (double)value->intval;
}
*count += 1;
arg = arg->right;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Map a user coordinate onto the 320x200 space the backend receives.
*
* With SCALE off this is the identity. With it on, BASIC's user coordinates run
* 0..xmax across the same screen, so the ratio is all there is to it.
*/
static void scale_point(akbasic_GraphicsState *gfx, double *x, double *y)
{
if ( !gfx->scaling ) {
return;
}
if ( gfx->xmax > 0.0 ) {
*x = (*x / gfx->xmax) * (double)AKBASIC_GRAPHICS_WIDTH;
}
if ( gfx->ymax > 0.0 ) {
*y = (*y / gfx->ymax) * (double)AKBASIC_GRAPHICS_HEIGHT;
}
}
/* ------------------------------------------------------------- GRAPHIC --- */
akerr_ErrorContext *akbasic_cmd_graphic(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[2];
int count = 0;
int mode = 0;
akbasic_Color background;
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "GRAPHIC"));
PASS(errctx, collect_numbers(obj, expr, "GRAPHIC", args, 2, &count));
FAIL_ZERO_RETURN(errctx, (count >= 1), AKBASIC_ERR_SYNTAX, "GRAPHIC expected a mode");
mode = (int)args[0];
/*
* BASIC 7.0 numbers five modes plus a CLR. They differ in bitmap resolution
* and in whether the bottom of the screen stays text, neither of which means
* anything against a host's renderer -- so the mode is recorded and only its
* one observable consequence is honoured: mode 0 is text, and text mode does
* not draw. Refusing an out-of-range mode still matters, because that is a
* typo the program author can fix.
*/
FAIL_ZERO_RETURN(errctx, (mode >= 0 && mode <= 5), AKBASIC_ERR_BOUNDS,
"GRAPHIC mode %d out of range (0 to 5)", mode);
if ( mode == 5 ) {
/* GRAPHIC CLR: drop the saved shapes and go back to text. */
PASS(errctx, obj->graphics->free_shapes(obj->graphics));
PASS(errctx, akbasic_graphics_state_init(&obj->gfx));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
obj->gfx.mode = mode;
if ( count >= 2 && args[1] != 0.0 ) {
PASS(errctx, akbasic_graphics_source_color(&obj->gfx, 0, &background));
PASS(errctx, obj->graphics->clear(obj->graphics, background));
obj->gfx.x = 0.0;
obj->gfx.y = 0.0;
}
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- COLOR --- */
akerr_ErrorContext *akbasic_cmd_color(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[2];
int count = 0;
int source = 0;
int index = 0;
(void)lval; (void)rval;
/*
* COLOR is the one verb in the group that does not touch the device: it
* binds a source to a palette index and the drawing verbs resolve that later.
* So it works with no graphics backend attached, which lets a program set its
* colors up before the host has lent it a renderer.
*/
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in COLOR");
PASS(errctx, collect_numbers(obj, expr, "COLOR", args, 2, &count));
FAIL_ZERO_RETURN(errctx, (count == 2), AKBASIC_ERR_SYNTAX,
"COLOR expected a source and a color");
source = (int)args[0];
index = (int)args[1];
FAIL_ZERO_RETURN(errctx, (source >= 0 && source < AKBASIC_COLOR_SOURCES),
AKBASIC_ERR_BOUNDS, "COLOR source %d out of range (0 to %d)",
source, AKBASIC_COLOR_SOURCES - 1);
FAIL_ZERO_RETURN(errctx, (index >= 1 && index <= 16), AKBASIC_ERR_BOUNDS,
"COLOR %d out of range (1 to 16)", index);
obj->gfx.source[source] = index;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- LOCATE --- */
akerr_ErrorContext *akbasic_cmd_locate(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[2];
int count = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in LOCATE");
PASS(errctx, collect_numbers(obj, expr, "LOCATE", args, 2, &count));
FAIL_ZERO_RETURN(errctx, (count == 2), AKBASIC_ERR_SYNTAX, "LOCATE expected X, Y");
/*
* LOCATE moves the *graphics* pixel cursor -- the point a DRAW with no
* starting coordinate begins from. The text cursor is CHAR's business. It
* needs no device, so it works before a renderer is attached.
*/
obj->gfx.x = args[0];
obj->gfx.y = args[1];
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- SCALE --- */
akerr_ErrorContext *akbasic_cmd_scale(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[3];
int count = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in SCALE");
PASS(errctx, collect_numbers(obj, expr, "SCALE", args, 3, &count));
FAIL_ZERO_RETURN(errctx, (count >= 1), AKBASIC_ERR_SYNTAX, "SCALE expected 0 or 1");
/* Pure coordinate arithmetic -- nothing reaches the device. */
obj->gfx.scaling = (args[0] != 0.0);
if ( count >= 3 ) {
FAIL_ZERO_RETURN(errctx, (args[1] > 0.0 && args[2] > 0.0), AKBASIC_ERR_VALUE,
"SCALE maxima must be positive");
obj->gfx.xmax = args[1];
obj->gfx.ymax = args[2];
} else if ( obj->gfx.scaling ) {
/* BASIC 7.0's default scaled space. */
obj->gfx.xmax = 1023.0;
obj->gfx.ymax = 1023.0;
}
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- DRAW --- */
akerr_ErrorContext *akbasic_cmd_draw(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[32];
int count = 0;
int i = 0;
akbasic_Color color;
double x = 0.0;
double y = 0.0;
double px = 0.0;
double py = 0.0;
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "DRAW"));
PASS(errctx, collect_numbers(obj, expr, "DRAW", args, 32, &count));
FAIL_ZERO_RETURN(errctx, (count >= 1), AKBASIC_ERR_SYNTAX,
"DRAW expected a color source");
FAIL_NONZERO_RETURN(errctx, (count > 1 && (count % 2) == 0), AKBASIC_ERR_SYNTAX,
"DRAW expected coordinates in X,Y pairs");
PASS(errctx, akbasic_graphics_source_color(&obj->gfx, (int)args[0], &color));
if ( count == 1 ) {
/* DRAW with no coordinates plots the pixel cursor, as 7.0 does. */
x = obj->gfx.x;
y = obj->gfx.y;
scale_point(&obj->gfx, &x, &y);
PASS(errctx, obj->graphics->point(obj->graphics, x, y, color));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
if ( count == 3 ) {
/* A single pair is a point, not a line from wherever the cursor was. */
x = args[1];
y = args[2];
scale_point(&obj->gfx, &x, &y);
PASS(errctx, obj->graphics->point(obj->graphics, x, y, color));
obj->gfx.x = args[1];
obj->gfx.y = args[2];
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* Two or more pairs is a polyline. Scale each end, never the segment. */
for ( i = 1; i + 3 < count + 1; i += 2 ) {
px = args[i];
py = args[i + 1];
x = args[i + 2];
y = args[i + 3];
scale_point(&obj->gfx, &px, &py);
scale_point(&obj->gfx, &x, &y);
PASS(errctx, obj->graphics->line(obj->graphics, px, py, x, y, color));
}
obj->gfx.x = args[count - 2];
obj->gfx.y = args[count - 1];
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------------- BOX --- */
akerr_ErrorContext *akbasic_cmd_box(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[6];
int count = 0;
int corner = 0;
akbasic_Color color;
double x1 = 0.0;
double y1 = 0.0;
double x2 = 0.0;
double y2 = 0.0;
double angle = 0.0;
double cx = 0.0;
double cy = 0.0;
double radians = 0.0;
double xs[4];
double ys[4];
double rx = 0.0;
double ry = 0.0;
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "BOX"));
PASS(errctx, collect_numbers(obj, expr, "BOX", args, 6, &count));
FAIL_ZERO_RETURN(errctx, (count >= 3), AKBASIC_ERR_SYNTAX,
"BOX expected a color source and at least one corner");
PASS(errctx, akbasic_graphics_source_color(&obj->gfx, (int)args[0], &color));
x1 = args[1];
y1 = args[2];
x2 = (count >= 5) ? args[3] : obj->gfx.x;
y2 = (count >= 5) ? args[4] : obj->gfx.y;
angle = (count >= 6) ? args[5] : 0.0;
scale_point(&obj->gfx, &x1, &y1);
scale_point(&obj->gfx, &x2, &y2);
if ( angle == 0.0 ) {
/*
* BASIC 7.0's last argument selects outline or fill. It is the sixth,
* and the fifth is the rotation, so a filled box is BOX s,x1,y1,x2,y2,a,1
* -- seven arguments, one more than this collects. Filling is therefore
* reached by the rotation being absent and is spelled with a negative
* angle; that is a deviation and is recorded in TODO.md section 5.
*/
PASS(errctx, obj->graphics->rect(obj->graphics, x1, y1, x2, y2, color));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/*
* A rotated box is four lines, because a renderer's rectangle is axis-aligned
* by construction. Rotation is about the box's own center, which is what 7.0
* does, and the angle is clockwise in degrees.
*/
cx = (x1 + x2) / 2.0;
cy = (y1 + y2) / 2.0;
rx = (x2 - x1) / 2.0;
ry = (y2 - y1) / 2.0;
radians = angle * (M_PI / 180.0);
xs[0] = -rx; ys[0] = -ry;
xs[1] = rx; ys[1] = -ry;
xs[2] = rx; ys[2] = ry;
xs[3] = -rx; ys[3] = ry;
for ( corner = 0; corner < 4; corner++ ) {
double ox = xs[corner];
double oy = ys[corner];
xs[corner] = cx + (ox * cos(radians)) - (oy * sin(radians));
ys[corner] = cy + (ox * sin(radians)) + (oy * cos(radians));
}
for ( corner = 0; corner < 4; corner++ ) {
PASS(errctx, obj->graphics->line(obj->graphics,
xs[corner], ys[corner],
xs[(corner + 1) % 4], ys[(corner + 1) % 4],
color));
}
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- CIRCLE --- */
akerr_ErrorContext *akbasic_cmd_circle(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[9];
int count = 0;
akbasic_Color color;
double cx = 0.0;
double cy = 0.0;
double xr = 0.0;
double yr = 0.0;
double start = 0.0;
double end = 360.0;
double rotation = 0.0;
double inc = CIRCLE_DEFAULT_INC;
double theta = 0.0;
double px = 0.0;
double py = 0.0;
double x = 0.0;
double y = 0.0;
bool first = true;
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "CIRCLE"));
PASS(errctx, collect_numbers(obj, expr, "CIRCLE", args, 9, &count));
FAIL_ZERO_RETURN(errctx, (count >= 4), AKBASIC_ERR_SYNTAX,
"CIRCLE expected a color source, a center and a radius");
PASS(errctx, akbasic_graphics_source_color(&obj->gfx, (int)args[0], &color));
cx = args[1];
cy = args[2];
xr = args[3];
yr = (count >= 5) ? args[4] : xr;
if ( count >= 7 ) {
start = args[5];
end = args[6];
}
rotation = (count >= 8) ? args[7] : 0.0;
inc = (count >= 9) ? args[8] : CIRCLE_DEFAULT_INC;
FAIL_ZERO_RETURN(errctx, (xr >= 0.0 && yr >= 0.0), AKBASIC_ERR_VALUE,
"CIRCLE radius must not be negative");
FAIL_ZERO_RETURN(errctx, (inc > 0.0), AKBASIC_ERR_VALUE,
"CIRCLE arc increment must be positive");
/*
* Drawn as a polygon of `inc`-degree segments rather than through the
* renderer's circle primitive. That is not a workaround for a missing API:
* BASIC 7.0's CIRCLE takes two radii, an arc range and a rotation, and is
* specified as an inc-degree polygon. A midpoint circle could only serve the
* one case where all of those are default, and a shape that changed
* character depending on whether the radii happened to be equal would be
* worse than one that is uniformly a polygon. TODO.md section 5.
*/
for ( theta = start; ; theta += inc ) {
double t = (theta > end) ? end : theta;
double radians = t * (M_PI / 180.0);
double ox = xr * sin(radians);
double oy = -yr * cos(radians);
double rot = rotation * (M_PI / 180.0);
x = cx + (ox * cos(rot)) - (oy * sin(rot));
y = cy + (ox * sin(rot)) + (oy * cos(rot));
scale_point(&obj->gfx, &x, &y);
if ( !first ) {
PASS(errctx, obj->graphics->line(obj->graphics, px, py, x, y, color));
}
first = false;
px = x;
py = y;
if ( t >= end ) {
break;
}
}
obj->gfx.x = cx;
obj->gfx.y = cy;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- PAINT --- */
akerr_ErrorContext *akbasic_cmd_paint(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
double args[4];
int count = 0;
akbasic_Color color;
double x = 0.0;
double y = 0.0;
bool partial = false;
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "PAINT"));
PASS(errctx, collect_numbers(obj, expr, "PAINT", args, 4, &count));
FAIL_ZERO_RETURN(errctx, (count >= 3), AKBASIC_ERR_SYNTAX,
"PAINT expected a color source and a coordinate");
PASS(errctx, akbasic_graphics_source_color(&obj->gfx, (int)args[0], &color));
x = args[1];
y = args[2];
scale_point(&obj->gfx, &x, &y);
/*
* The akgl flood fill walks spans from a fixed stack and reports
* AKERR_OUTOFBOUNDS when it runs out, having filled *part* of the region.
* Reporting that to the program is the whole point: a screen that came back
* half-painted with no diagnostic is worse than an error, because the
* program cannot tell it happened.
*/
ATTEMPT {
CATCH(errctx, obj->graphics->paint(obj->graphics, (int)x, (int)y, color));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
partial = true;
} FINISH(errctx, true);
/*
* Raised out here rather than inside the HANDLE block. HANDLE sets
* handled = true on the context, and a FAIL_RETURN from inside it hands the
* caller a context already marked handled -- whose FINISH_LOGIC then declines
* to pass it up and releases it instead. The error vanishes and PAINT looks
* like it worked. Flag inside, raise outside.
*/
FAIL_NONZERO_RETURN(errctx, partial, AKBASIC_ERR_DEVICE,
"PAINT filled only part of the region: the device ran out of fill space");
obj->gfx.x = args[1];
obj->gfx.y = args[2];
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------- SSHAPE and GSHAPE --- */
/**
* @brief Find the string variable a shape verb reads or writes.
*
* SSHAPE and GSHAPE are the only two verbs in the group whose first argument is
* an identifier rather than a number, so they walk the leaf themselves instead
* of going through collect_numbers().
*/
static akerr_ErrorContext *shape_variable(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, akbasic_Variable **dest, akbasic_ASTLeaf **rest)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *arg = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && dest != NULL && rest != NULL),
AKERR_NULLPOINTER, "NULL argument in shape_variable");
arg = akbasic_leaf_first_argument(expr);
FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX,
"%s expected a string variable", verb);
FAIL_ZERO_RETURN(errctx, (arg->leaftype == AKBASIC_LEAF_IDENTIFIER_STRING),
AKBASIC_ERR_TYPE, "%s expected a string variable", verb);
PASS(errctx, akbasic_environment_get(obj->environment, arg->identifier, dest));
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKBASIC_ERR_UNDEFINED,
"%s could not reach the variable %s", verb, arg->identifier);
*rest = arg->right;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_sshape(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
akbasic_ASTLeaf *arg = NULL;
akbasic_Value *value = NULL;
double coords[4];
int count = 0;
int handle = -1;
char encoded[AKBASIC_MAX_STRING_LENGTH];
int64_t zerosubscript[1] = { 0 };
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "SSHAPE"));
PASS(errctx, shape_variable(obj, expr, "SSHAPE", &variable, &arg));
while ( arg != NULL && count < 4 ) {
PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING),
AKBASIC_ERR_TYPE, "SSHAPE expected a number in argument %d", count + 2);
coords[count] = (value->valuetype == AKBASIC_TYPE_FLOAT)
? value->floatval : (double)value->intval;
count += 1;
arg = arg->right;
}
FAIL_ZERO_RETURN(errctx, (count >= 2), AKBASIC_ERR_SYNTAX,
"SSHAPE expected A$, X1, Y1");
if ( count < 4 ) {
coords[2] = obj->gfx.x;
coords[3] = obj->gfx.y;
}
PASS(errctx, obj->graphics->save_shape(obj->graphics,
(int)coords[0], (int)coords[1],
(int)coords[2], (int)coords[3], &handle));
/*
* A real C128 packs the pixels into the string. Here a value's string is a
* fixed 256 bytes and the region is a device surface, so what goes in is a
* handle to it. A program can still pass the string to GSHAPE, which is all
* BASIC ever does with it; what it can no longer do is save it to disk or
* take its LEN and get a size. TODO.md section 5.
*/
snprintf(encoded, sizeof(encoded), "%s%d", SHAPE_PREFIX, handle);
PASS(errctx, akbasic_variable_set_string(variable, encoded, zerosubscript, 1));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_cmd_gshape(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
akbasic_ASTLeaf *arg = NULL;
akbasic_Value *value = NULL;
akbasic_Value *stored = NULL;
double coords[2];
int count = 0;
int64_t handle = 0;
double x = 0.0;
double y = 0.0;
int64_t zerosubscript[1] = { 0 };
(void)lval; (void)rval;
PASS(errctx, require_graphics(obj, "GSHAPE"));
PASS(errctx, shape_variable(obj, expr, "GSHAPE", &variable, &arg));
while ( arg != NULL && count < 2 ) {
PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING),
AKBASIC_ERR_TYPE, "GSHAPE expected a number in argument %d", count + 2);
coords[count] = (value->valuetype == AKBASIC_TYPE_FLOAT)
? value->floatval : (double)value->intval;
count += 1;
arg = arg->right;
}
x = (count >= 1) ? coords[0] : obj->gfx.x;
y = (count >= 2) ? coords[1] : obj->gfx.y;
PASS(errctx, akbasic_variable_get_subscript(variable, zerosubscript, 1, &stored));
FAIL_ZERO_RETURN(errctx,
(strncmp(stored->stringval, SHAPE_PREFIX, strlen(SHAPE_PREFIX)) == 0),
AKBASIC_ERR_VALUE,
"GSHAPE was given a string that did not come from SSHAPE");
PASS(errctx, akbasic_str_to_int64(stored->stringval + strlen(SHAPE_PREFIX), 10, &handle));
scale_point(&obj->gfx, &x, &y);
PASS(errctx, obj->graphics->paste_shape(obj->graphics, (int)handle, x, y));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}

View File

@@ -37,19 +37,25 @@ static const akbasic_Verb VERBS[] = {
{ "AND", AKBASIC_TOK_AND, -1, NULL, NULL },
{ "ATN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_atn },
{ "AUTO", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_auto },
{ "BOX", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_box },
{ "CHR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_chr },
{ "CIRCLE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_circle },
{ "COLOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_color },
{ "COS", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_cos },
{ "DATA", AKBASIC_TOK_COMMAND, -1, akbasic_parse_data, akbasic_cmd_data },
{ "DEF", AKBASIC_TOK_COMMAND, -1, akbasic_parse_def, akbasic_cmd_def },
{ "DELETE", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_delete },
{ "DIM", AKBASIC_TOK_COMMAND, -1, akbasic_parse_dim, akbasic_cmd_dim },
{ "DLOAD", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_dload },
{ "DRAW", AKBASIC_TOK_COMMAND, -1, akbasic_parse_draw, akbasic_cmd_draw },
{ "DSAVE", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_dsave },
{ "ELSE", AKBASIC_TOK_COMMAND, -1, NULL, NULL },
{ "EXIT", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_exit },
{ "FOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_for, akbasic_cmd_for },
{ "GOSUB", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_gosub },
{ "GOTO", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_goto },
{ "GRAPHIC", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_graphic },
{ "GSHAPE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_gshape },
{ "HEX", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_hex },
{ "IF", AKBASIC_TOK_COMMAND, -1, akbasic_parse_if, akbasic_cmd_if },
{ "INPUT", AKBASIC_TOK_COMMAND, -1, akbasic_parse_input, akbasic_cmd_input },
@@ -59,12 +65,14 @@ static const akbasic_Verb VERBS[] = {
{ "LEN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_len },
{ "LET", AKBASIC_TOK_COMMAND, -1, akbasic_parse_let, akbasic_cmd_let },
{ "LIST", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_list },
{ "LOCATE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_locate },
{ "LOG", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_log },
{ "MID", AKBASIC_TOK_FUNCTION, 3, NULL, akbasic_fn_mid },
{ "MOD", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_mod },
{ "NEXT", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_next },
{ "NOT", AKBASIC_TOK_NOT, -1, NULL, NULL },
{ "OR", AKBASIC_TOK_OR, -1, NULL, NULL },
{ "PAINT", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_paint },
{ "PEEK", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_peek },
{ "POINTER", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_pointer },
{ "POINTERVAR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_pointervar },
@@ -77,11 +85,13 @@ static const akbasic_Verb VERBS[] = {
{ "RETURN", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_return },
{ "RIGHT", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_right },
{ "RUN", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_run },
{ "SCALE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_scale },
{ "SGN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_sgn },
{ "SHL", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shl },
{ "SHR", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shr },
{ "SIN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_sin },
{ "SPC", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_spc },
{ "SSHAPE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sshape },
{ "STEP", AKBASIC_TOK_COMMAND, -1, NULL, NULL },
{ "STOP", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_stop },
{ "STR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_str },

View File

@@ -15,7 +15,9 @@
#include <akbasic/verbs.h>
/* Parse handlers -- src/parser_commands.c */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_arglist(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_data(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_draw(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_def(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_dim(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_parse_for(struct akbasic_Parser *parser, akbasic_ASTLeaf **dest);
@@ -79,4 +81,16 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_tan(struct akbasic_Runtime *obj, a
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_val(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_xor(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
/* Group G graphics verbs -- src/runtime_graphics.c */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_box(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_circle(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_color(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_draw(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_graphic(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_gshape(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_locate(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_paint(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_scale(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sshape(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
#endif // _AKBASIC_SRC_VERBS_H_

287
tests/graphics_verbs.c Normal file
View File

@@ -0,0 +1,287 @@
/**
* @file graphics_verbs.c
* @brief Tests the group G verbs against the recording mock backend.
*
* These verbs emit nothing a golden file can compare, so the assertions are on
* the call log: what reached the device, in what order, with what geometry and
* what color. Running a real BASIC line rather than calling the handler directly
* is deliberate -- it exercises the dispatch-table row and the parse handler as
* well, which is where an added verb is most likely to be wrong.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/graphics.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "mockdevice.h"
#include "testutil.h"
/** @brief Bring up a runtime with the mock devices attached and a program loaded. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
mock_devices_init();
PASS(errctx, akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS,
&MOCK_AUDIO, &MOCK_INPUT));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
/** @brief The white of palette index 2, which most of these draw with. */
#define WHITE "#ffffff"
/** @brief The red of palette index 3. */
#define RED "#883932"
/**
* @brief A single DRAW coordinate pair plots a point; two or more draw a
* polyline; none at all plots wherever LOCATE left the cursor.
*/
static void test_draw(void)
{
TEST_REQUIRE_OK(run_program("10 DRAW 1, 10, 20\n"));
TEST_REQUIRE_STR(MOCK.log, "point 10.0,20.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 DRAW 1, 0, 0 TO 10, 0 TO 10, 10\n"));
TEST_REQUIRE_STR(MOCK.log,
"line 0.0,0.0-10.0,0.0 " WHITE "\n"
"line 10.0,0.0-10.0,10.0 " WHITE "\n");
harness_stop();
/* LOCATE moves the pixel cursor; a bare DRAW plots it. */
TEST_REQUIRE_OK(run_program("10 LOCATE 33, 44\n20 DRAW 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 33.0,44.0 " WHITE "\n");
harness_stop();
/* An odd number of coordinates is a typo, not half a line. */
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 DRAW 1, 5\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "X,Y pairs") != NULL,
"an odd coordinate count should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief COLOR rebinds a source, and every drawing verb follows the rebinding. */
static void test_color(void)
{
TEST_REQUIRE_OK(run_program("10 COLOR 1, 3\n20 DRAW 1, 1, 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 1.0,1.0 " RED "\n");
harness_stop();
/* Both arguments are range-checked, and BASIC counts colors from 1. */
TEST_REQUIRE_OK(run_program("10 COLOR 9, 3\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR source 9") != NULL,
"an out-of-range source should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 COLOR 1, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR 0") != NULL,
"color 0 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief An unrotated BOX is a rectangle; a rotated one is four lines. */
static void test_box(void)
{
TEST_REQUIRE_OK(run_program("10 BOX 1, 0, 0, 10, 10\n"));
TEST_REQUIRE_STR(MOCK.log, "rect 0.0,0.0-10.0,10.0 " WHITE "\n");
harness_stop();
/*
* Rotated 90 degrees about its own center, a square lands back on itself --
* same four edges, walked from a different starting corner. That is the
* assertion worth making, because it pins the rotation *origin*: rotating
* about the coordinate origin instead of the box's center would put the
* square somewhere else entirely, and rotating about a corner would tilt it.
*
* The -0.0 is real and harmless: cos(pi/2) is not exactly zero, so one
* coordinate lands on negative zero, which compares equal to zero everywhere
* it matters and only shows up because the mock prints it.
*/
TEST_REQUIRE_OK(run_program("10 BOX 1, 0, 0, 10, 10, 90\n"));
TEST_REQUIRE_STR(MOCK.log,
"line 10.0,-0.0-10.0,10.0 " WHITE "\n"
"line 10.0,10.0-0.0,10.0 " WHITE "\n"
"line 0.0,10.0-0.0,0.0 " WHITE "\n"
"line 0.0,0.0-10.0,-0.0 " WHITE "\n");
harness_stop();
}
/** @brief CIRCLE is a polygon of arc-increment segments, and closes. */
static void test_circle(void)
{
int segments = 0;
const char *cursor = NULL;
/* 360 degrees at 90 per segment is four lines, whatever the radius. */
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, 10, 10, 0, 360, 0, 90\n"));
for ( cursor = MOCK.log; (cursor = strstr(cursor, "line ")) != NULL; cursor += 5 ) {
segments += 1;
}
TEST_REQUIRE_INT(segments, 4);
/* The first vertex is directly above the center, as BASIC 7.0 starts it. */
TEST_REQUIRE(strncmp(MOCK.log, "line 50.0,40.0-", 15) == 0,
"CIRCLE should start at the top of the circle, got \"%.30s\"", MOCK.log);
harness_stop();
/* A negative radius and a zero increment are both refusals, not loops. */
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, -5\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "radius") != NULL,
"a negative radius should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, 10, 10, 0, 360, 0, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "increment") != NULL,
"a zero arc increment should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief PAINT reports a partial fill rather than letting it pass as success.
*
* The device gives up when its span stack runs out, having filled some of the
* region. A program that cannot tell that happened has no way to recover, so the
* error is the feature.
*/
static void test_paint(void)
{
TEST_REQUIRE_OK(run_program("10 PAINT 1, 5, 5\n"));
TEST_REQUIRE_STR(MOCK.log, "paint 5,5 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK.paint_exhausts = true;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PAINT 1, 5, 5\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "only part of the region") != NULL,
"a partial fill should be reported, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief SCALE maps user coordinates onto the 320x200 space, and only then. */
static void test_scale(void)
{
/* Off by default: a coordinate passes through untouched. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, 160, 100\n"));
TEST_REQUIRE_STR(MOCK.log, "point 160.0,100.0 " WHITE "\n");
harness_stop();
/* On, with the 7.0 default maxima: the middle of the user space is the
middle of the screen. */
TEST_REQUIRE_OK(run_program("10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_STR(MOCK.log, "point 320.0,200.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 SCALE 1, 640, 400\n20 DRAW 1, 320, 200\n"));
TEST_REQUIRE_STR(MOCK.log, "point 160.0,100.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 SCALE 1, 0, 400\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "positive") != NULL,
"a zero SCALE maximum should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief GRAPHIC records the mode, clears on request and refuses a bad mode. */
static void test_graphic(void)
{
TEST_REQUIRE_OK(run_program("10 GRAPHIC 1, 1\n"));
TEST_REQUIRE_STR(MOCK.log, "clear #000000\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.mode, 1);
harness_stop();
/* Without the clear argument nothing reaches the device. */
TEST_REQUIRE_OK(run_program("10 GRAPHIC 1\n"));
TEST_REQUIRE_STR(MOCK.log, "");
harness_stop();
/* GRAPHIC CLR drops the saved shapes and resets the whole state. */
TEST_REQUIRE_OK(run_program("10 COLOR 1, 3\n20 GRAPHIC 5\n"));
TEST_REQUIRE_STR(MOCK.log, "freeshapes\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.source[1], 2);
harness_stop();
TEST_REQUIRE_OK(run_program("10 GRAPHIC 9\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "GRAPHIC mode 9") != NULL,
"an out-of-range mode should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief SSHAPE stores a handle a later GSHAPE can use, and only that. */
static void test_shapes(void)
{
TEST_REQUIRE_OK(run_program("10 SSHAPE A$, 0, 0, 8, 8\n20 GSHAPE A$, 32, 32\n"));
TEST_REQUIRE_STR(MOCK.log, "save 0,0-8,8 -> 0\npaste 0 at 32.0,32.0\n");
harness_stop();
/*
* The string is opaque but not magic: a string that did not come from SSHAPE
* has to be refused, or GSHAPE parses whatever digits it finds and pastes
* some unrelated slot.
*/
TEST_REQUIRE_OK(run_program("10 A$ = \"5\"\n20 GSHAPE A$, 0, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "did not come from SSHAPE") != NULL,
"a hand-written shape string should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A numeric variable is the wrong type for either verb. */
TEST_REQUIRE_OK(run_program("10 SSHAPE A#, 0, 0, 8, 8\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "string variable") != NULL,
"SSHAPE into a number should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief Every verb that needs a device says so by name when it has none.
*
* This is the standalone driver's situation, so it is the common path. COLOR,
* LOCATE and SCALE are excluded on purpose: they only touch interpreter state
* and must keep working before a host has lent the script a renderer.
*/
static void test_no_device(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 COLOR 1, 3\n"
"20 LOCATE 5, 5\n"
"30 SCALE 1\n"
"40 DRAW 1, 1, 1\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DRAW needs a graphics device") != NULL,
"DRAW without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR") == NULL,
"COLOR should not need a device, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.source[1], 3);
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.gfx.x, 5.0);
TEST_REQUIRE(HARNESS_RUNTIME.gfx.scaling, "SCALE should not need a device");
harness_stop();
}
int main(void)
{
test_draw();
test_color();
test_box();
test_circle();
test_paint();
test_scale();
test_graphic();
test_shapes();
test_no_device();
return akbasic_test_failures;
}

View File

@@ -0,0 +1,6 @@
10 REM A range check reported through the driver, end to end. The program stops
20 REM at the first error, so this file covers one; the rest of the checks are
30 REM asserted in tests/graphics_verbs.c against the recording backend.
40 PRINT "BEFORE"
50 SCALE 1, 0, 400
60 PRINT "UNREACHABLE"

View File

@@ -0,0 +1,3 @@
BEFORE
? 50 : RUNTIME ERROR SCALE maxima must be positive

View File

@@ -0,0 +1,8 @@
10 REM The standalone driver lends the script no graphics device.
20 REM COLOR, LOCATE and SCALE only touch interpreter state and must still work.
30 COLOR 1, 3
40 LOCATE 40, 50
50 SCALE 1, 640, 400
60 PRINT "STATE VERBS OK"
70 REM DRAW needs the device, and has to say which verb wanted it.
80 DRAW 1, 10, 10

View File

@@ -0,0 +1,3 @@
STATE VERBS OK
? 80 : RUNTIME ERROR DRAW needs a graphics device and this runtime has none