diff --git a/CMakeLists.txt b/CMakeLists.txt index b4b4c6d..bc25c8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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=$ + -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. # diff --git a/TODO.md b/TODO.md index b432164..de89f3a 100644 --- a/TODO.md +++ b/TODO.md @@ -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 1–12 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:` 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 diff --git a/include/akbasic/graphics.h b/include/akbasic/graphics.h index f17ccdf..48f8b94 100644 --- a/include/akbasic/graphics.h +++ b/include/akbasic/graphics.h @@ -23,6 +23,20 @@ #include +/** + * @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_ diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 4c03bce..84f22bd 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -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 diff --git a/src/graphics_tables.c b/src/graphics_tables.c index c51908f..2792a30 100644 --- a/src/graphics_tables.c +++ b/src/graphics_tables.c @@ -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); +} diff --git a/src/parser_commands.c b/src/parser_commands.c index 9218b48..9cf3eef 100644 --- a/src/parser_commands.c +++ b/src/parser_commands.c @@ -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); diff --git a/src/runtime.c b/src/runtime.c index 06501a7..e5f3d60 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -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)); diff --git a/src/runtime_graphics.c b/src/runtime_graphics.c new file mode 100644 index 0000000..f2870fe --- /dev/null +++ b/src/runtime_graphics.c @@ -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 +#include +#include + +#include + +#include +#include +#include + +#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); +} diff --git a/src/verbs.c b/src/verbs.c index b1bda58..98eb6fb 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -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 }, diff --git a/src/verbs.h b/src/verbs.h index 473b833..b1e3d3c 100644 --- a/src/verbs.h +++ b/src/verbs.h @@ -15,7 +15,9 @@ #include /* 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_ diff --git a/tests/graphics_verbs.c b/tests/graphics_verbs.c new file mode 100644 index 0000000..80b77ff --- /dev/null +++ b/tests/graphics_verbs.c @@ -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 + +#include +#include +#include + +#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; +} diff --git a/tests/language/graphics/argument_errors.bas b/tests/language/graphics/argument_errors.bas new file mode 100644 index 0000000..5efbf97 --- /dev/null +++ b/tests/language/graphics/argument_errors.bas @@ -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" diff --git a/tests/language/graphics/argument_errors.txt b/tests/language/graphics/argument_errors.txt new file mode 100644 index 0000000..471dfc8 --- /dev/null +++ b/tests/language/graphics/argument_errors.txt @@ -0,0 +1,3 @@ +BEFORE +? 50 : RUNTIME ERROR SCALE maxima must be positive + diff --git a/tests/language/graphics/no_device.bas b/tests/language/graphics/no_device.bas new file mode 100644 index 0000000..7af43f6 --- /dev/null +++ b/tests/language/graphics/no_device.bas @@ -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 diff --git a/tests/language/graphics/no_device.txt b/tests/language/graphics/no_device.txt new file mode 100644 index 0000000..16a7bd9 --- /dev/null +++ b/tests/language/graphics/no_device.txt @@ -0,0 +1,3 @@ +STATE VERBS OK +? 80 : RUNTIME ERROR DRAW needs a graphics device and this runtime has none +