diff --git a/CMakeLists.txt b/CMakeLists.txt index b5df479..b4b4c6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,7 @@ set(AKBASIC_SOURCES src/environment.c src/error.c src/grammar.c + src/graphics_tables.c src/parser.c src/parser_commands.c src/runtime.c @@ -186,6 +187,7 @@ endif() # --------------------------------------------------------------------------- set(AKBASIC_TESTS convert + devices environment_scope error_codes grammar_leaves diff --git a/include/akbasic/audio.h b/include/akbasic/audio.h new file mode 100644 index 0000000..ee9ba47 --- /dev/null +++ b/include/akbasic/audio.h @@ -0,0 +1,71 @@ +/** + * @file audio.h + * @brief Declares the audio backend: where SOUND, PLAY, ENVELOPE and VOL land. + * + * Same reasoning as graphics.h -- the core library is free of SDL, so the sound + * verbs call through a record of function pointers and akbasic_audio_init_akgl() + * in the akbasic_akgl target wires that record to akgl_audio_*. + * + * The libakgl API this is modelled on is a tone generator, not a sample player: + * three voices, a waveform and an ADSR envelope each, mixed to one stream. That + * is the right shape, because BASIC 7.0's sound verbs describe notes rather than + * recordings. What it does not have is a filter stage, which is why FILTER is + * refused -- see TODO.md section 7. + */ + +#ifndef _AKBASIC_AUDIO_H_ +#define _AKBASIC_AUDIO_H_ + +#include + +#include + +/** @brief Number of independent voices, matching the SID and matching libakgl. */ +#define AKBASIC_AUDIO_VOICES 3 + +/** + * @brief Waveform selection, numbered as BASIC 7.0's SOUND numbers it. + * + * Declared here rather than reused from akgl/audio.h so the core library does + * not include a libakgl header. The akgl backend maps these across; the values + * happen to agree today and the mapping is still written out, because two + * enumerations agreeing by accident is not a contract. + */ +typedef enum +{ + AKBASIC_WAVE_TRIANGLE = 0, /* SOUND waveform 0 */ + AKBASIC_WAVE_SAWTOOTH = 1, /* SOUND waveform 1 */ + AKBASIC_WAVE_SQUARE = 2, /* SOUND waveform 2, and the power-on default */ + AKBASIC_WAVE_NOISE = 3 /* SOUND waveform 3 */ +} akbasic_Waveform; + +/** + * @brief Where the sound verbs play. + * + * Voices here are numbered from zero. BASIC numbers them 1 through 3 and the + * conversion happens once, in the verb handler, so a backend never sees a BASIC + * voice number. + * + * Durations are milliseconds. BASIC counts in jiffies (1/60 s) and note lengths + * derived from TEMPO; both are converted in src/audio_tables.c before they get + * here, so a backend never sees a jiffy either. + */ +typedef struct akbasic_AudioBackend +{ + void *self; + + /** Start a note on a voice, for a bounded duration. */ + akerr_ErrorContext AKERR_NOIGNORE *(*tone)(struct akbasic_AudioBackend *self, int voice, double hz, int ms); + /** Silence a voice immediately. */ + akerr_ErrorContext AKERR_NOIGNORE *(*stop)(struct akbasic_AudioBackend *self, int voice); + /** Select the waveform a voice synthesises. */ + akerr_ErrorContext AKERR_NOIGNORE *(*waveform)(struct akbasic_AudioBackend *self, int voice, akbasic_Waveform waveform); + /** Set a voice's ADSR envelope. Sustain is a level from 0.0 to 1.0, not a time. */ + akerr_ErrorContext AKERR_NOIGNORE *(*envelope)(struct akbasic_AudioBackend *self, int voice, int attack, int decay, double sustain, int release); + /** Set the master output level, 0.0 to 1.0. */ + akerr_ErrorContext AKERR_NOIGNORE *(*volume)(struct akbasic_AudioBackend *self, double level); + /** Report whether a voice is still sounding. PLAY's queue uses this to pace itself. */ + akerr_ErrorContext AKERR_NOIGNORE *(*voice_active)(struct akbasic_AudioBackend *self, int voice, bool *active); +} akbasic_AudioBackend; + +#endif // _AKBASIC_AUDIO_H_ diff --git a/include/akbasic/error.h b/include/akbasic/error.h index f8ca5e1..5ffca2d 100644 --- a/include/akbasic/error.h +++ b/include/akbasic/error.h @@ -50,6 +50,8 @@ enum { AKBASIC_ERR_ENVIRONMENT, /** Environment pool exhausted or orphaned environment */ AKBASIC_ERR_VALUE, /** A value was malformed, truncated or unconvertible */ AKBASIC_ERR_STATE, /** A verb ran outside the block structure it requires */ + AKBASIC_ERR_DEVICE, /** A verb needs a graphics, audio or input backend the runtime has not been given, or one that cannot do what was asked */ + AKBASIC_ERR_LAST, /** One past the last real code. Not a status; tests/error_codes.c walks up to it so a new code is checked for a name without anybody remembering to widen the loop. */ AKBASIC_ERR_LIMIT = AKBASIC_ERR_BASE + 256 }; diff --git a/include/akbasic/graphics.h b/include/akbasic/graphics.h new file mode 100644 index 0000000..f17ccdf --- /dev/null +++ b/include/akbasic/graphics.h @@ -0,0 +1,105 @@ +/** + * @file graphics.h + * @brief Declares the graphics backend: where DRAW, BOX, CIRCLE and PAINT land. + * + * The core library is free of SDL and builds with no libakgl present, so the + * graphics verbs cannot call akgl_draw_* directly. They call through a record of + * function pointers instead -- the same shape as akbasic_TextSink, and the same + * shape libakgl itself uses for akgl_RenderBackend and akgl_PhysicsBackend. + * + * akbasic_graphics_init_akgl() lives in the separate akbasic_akgl target and + * draws through whatever renderer the host game already initialized. A host that + * renders some other way supplies its own record and never links libakgl at all. + * + * A runtime with no graphics backend is the normal case -- the standalone driver + * has none -- so every graphics verb refuses with AKBASIC_ERR_DEVICE rather than + * dereferencing a NULL vtable. + */ + +#ifndef _AKBASIC_GRAPHICS_H_ +#define _AKBASIC_GRAPHICS_H_ + +#include + +#include + +/** + * @brief An RGBA color, in the shape SDL_Color has without requiring SDL. + * + * BASIC never names a color this way -- COLOR takes a source and a 1-16 palette + * index -- so this is what the palette table in src/graphics_tables.c converts + * to, and what the backend receives. + */ +typedef struct +{ + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t a; +} akbasic_Color; + +/** + * @brief Where the graphics verbs draw. + * + * Coordinates are `double` rather than an integer pixel address because SCALE + * makes them fractional: a program running at a 1023x1023 logical scale on a + * 320x200 screen produces non-integer pixel positions, and rounding at each verb + * rather than once at the backend accumulates visible drift along a polyline. + * + * There is deliberately no circle entry point. BASIC 7.0's CIRCLE takes two + * radii, a start and end angle, a rotation and a degree increment -- it is a + * polygon by definition -- so it is built from `line` calls in the verb handler. + * See TODO.md section 5. + */ +typedef struct akbasic_GraphicsBackend +{ + void *self; + + /** Plot one pixel. */ + akerr_ErrorContext AKERR_NOIGNORE *(*point)(struct akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color); + /** Draw a line between two points. */ + akerr_ErrorContext AKERR_NOIGNORE *(*line)(struct akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color); + /** Outline an axis-aligned rectangle. A rotated BOX becomes four `line` calls. */ + akerr_ErrorContext AKERR_NOIGNORE *(*rect)(struct akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color); + /** Fill an axis-aligned rectangle. */ + akerr_ErrorContext AKERR_NOIGNORE *(*filled_rect)(struct akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color); + /** + * Flood-fill the region containing (x, y). + * + * May report AKERR_OUTOFBOUNDS having filled only part of the region: the + * akgl implementation walks spans from a fixed stack and gives up rather + * than growing it. PAINT reports that to the program instead of leaving a + * half-painted screen unexplained. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*paint)(struct akbasic_GraphicsBackend *self, int x, int y, akbasic_Color color); + /** Clear the whole drawing surface to one color. */ + akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_GraphicsBackend *self, akbasic_Color color); + + /** + * Save a rectangular region and yield an opaque handle to it. + * + * SSHAPE stores the handle in a BASIC string, not the pixels -- see TODO.md + * section 5 for what that deviation costs. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*save_shape)(struct akbasic_GraphicsBackend *self, int x1, int y1, int x2, int y2, int *handle); + /** Blit a saved region back at (x, y). */ + akerr_ErrorContext AKERR_NOIGNORE *(*paste_shape)(struct akbasic_GraphicsBackend *self, int handle, double x, double y); + /** Release every saved region. NEW and CLR call this; nothing else reclaims a slot. */ + akerr_ErrorContext AKERR_NOIGNORE *(*free_shapes)(struct akbasic_GraphicsBackend *self); +} akbasic_GraphicsBackend; + +/** + * @brief Convert a BASIC color index to the RGBA the backend draws with. + * + * BASIC 7.0 numbers its sixteen colors from 1, not 0; index 17 is the "current" + * color on a real C128 and has no meaning here. + * + * @param index Palette index, 1 through 16. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `dest` is NULL. + * @throws AKBASIC_ERR_BOUNDS When `index` is outside 1..16. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_palette(int index, akbasic_Color *dest); + +#endif // _AKBASIC_GRAPHICS_H_ diff --git a/include/akbasic/input.h b/include/akbasic/input.h new file mode 100644 index 0000000..037f7fc --- /dev/null +++ b/include/akbasic/input.h @@ -0,0 +1,42 @@ +/** + * @file input.h + * @brief Declares the input backend: where GET and GETKEY read from. + * + * Same reasoning as graphics.h and audio.h. The libakgl implementation is + * akgl_controller_poll_key(), which drains a keystroke ring the library fills + * from SDL events the *host* pumps -- so the interpreter answers "is there a key + * waiting" without owning the event loop, which is what goal 3 requires. + * + * One thing an embedding host should know: that ring is process-global and the + * host's own control maps read the same events. An interpreter sitting in a + * GET loop drains keystrokes the game will then never see. A host that cares + * either withholds the input backend or supplies a filtered one of its own. + */ + +#ifndef _AKBASIC_INPUT_H_ +#define _AKBASIC_INPUT_H_ + +#include + +#include + +/** + * @brief Where the console input verbs read. + */ +typedef struct akbasic_InputBackend +{ + void *self; + + /** + * Take the next buffered keystroke without waiting for one. + * + * An empty buffer is success with `*available` false, never an error: GET on + * an empty buffer yields the empty string, which is ordinary BASIC and + * happens on most iterations of any GET loop. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*poll_key)(struct akbasic_InputBackend *self, int *keycode, bool *available); + /** Discard everything buffered. */ + akerr_ErrorContext AKERR_NOIGNORE *(*flush_keys)(struct akbasic_InputBackend *self); +} akbasic_InputBackend; + +#endif // _AKBASIC_INPUT_H_ diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 382fd75..4c03bce 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -18,8 +18,11 @@ #include +#include #include #include +#include +#include #include #include #include @@ -84,6 +87,27 @@ typedef struct akbasic_Runtime akbasic_Environment *environment; akbasic_TextSink *sink; + /* + * The device backends, any of which may be NULL. The standalone driver + * supplies none of them, so a PRINT-only program must keep working; every + * verb that needs one refuses with AKBASIC_ERR_DEVICE instead. + */ + akbasic_GraphicsBackend *graphics; + akbasic_AudioBackend *audio; + akbasic_InputBackend *input; + + /* + * 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 + * block, so the caller that owns the frame owns the time. libakgl does the + * same thing -- akgl_actor_logic_changeframe() takes curtimems as an + * argument rather than asking the system for it. + * + * Left at zero, every duration expires on the step after it starts. That is + * wrong but never a hang, which is the right way for it to fail. + */ + int64_t timems; + /* REPL line assembly */ char userline[AKBASIC_MAX_LINE_LENGTH]; @@ -104,6 +128,43 @@ typedef struct akbasic_Runtime */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink *sink); +/** + * @brief Attach the device backends a host is willing to lend the interpreter. + * + * Any argument may be NULL, which is how a host withholds a capability: a script + * given no audio backend gets an error from SOUND rather than silence. Call it + * after akbasic_runtime_init() and before akbasic_runtime_start(); calling it + * again mid-run is allowed and takes effect on the next verb. + * + * @param obj Object to initialize, inspect, or modify. + * @param graphics Where DRAW, BOX, CIRCLE and PAINT land; may be NULL. + * @param audio Where SOUND, PLAY and VOL land; may be NULL. + * @param input Where GET and GETKEY read; may be NULL. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_GraphicsBackend *graphics, akbasic_AudioBackend *audio, akbasic_InputBackend *input); + +/** + * @brief Tell the interpreter what time the host thinks it is. + * + * SOUND durations, PLAY note lengths and TEMPO are all time-based, and section + * 1.6 forbids the library blocking or reading a clock of its own. A host calls + * this once a frame before akbasic_runtime_run(); the standalone driver calls it + * from a monotonic clock each step. + * + * Time is only ever compared, never differenced against a wall clock, so any + * monotonic millisecond source will do. It is not required to advance, and a + * host that never calls this leaves it at zero -- durations then expire + * immediately, which is audible but never a hang. + * + * @param obj Object to initialize, inspect, or modify. + * @param timems The host's current time in milliseconds. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_settime(akbasic_Runtime *obj, int64_t timems); + /** * @brief Reset the per-line state without disturbing the program or variables. * @param obj Object to initialize, inspect, or modify. diff --git a/src/error.c b/src/error.c index 5fbac4e..61c251f 100644 --- a/src/error.c +++ b/src/error.c @@ -34,5 +34,7 @@ akerr_ErrorContext *akbasic_error_register(void) "Value Error")); PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_STATE, "State Error")); + PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_DEVICE, + "Device Error")); SUCCEED_RETURN(errctx); } diff --git a/src/graphics_tables.c b/src/graphics_tables.c new file mode 100644 index 0000000..c51908f --- /dev/null +++ b/src/graphics_tables.c @@ -0,0 +1,53 @@ +/** + * @file graphics_tables.c + * @brief Implements the BASIC 7.0 color palette lookup. + * + * This is a table, so it is laid out as one. The RGB values are the widely + * reproduced VIC-II palette (Pepto's measurement of a PAL 6569R3), which is what + * an emulator shows and therefore what somebody porting a listing expects to + * see. They are not a C128 ROM constant -- the real machine has no RGB anywhere + * in it, it has a chroma/luma encoder -- so this is a choice, not a transcription. + */ + +#include + +#include +#include + +/** + * @brief The sixteen BASIC 7.0 colors, indexed from 1 as BASIC indexes them. + * + * Slot 0 is unused and left black so an off-by-one reads as black rather than as + * whatever the last entry happened to be. + */ +static const akbasic_Color PALETTE[17] = { + { 0x00, 0x00, 0x00, 0xff }, /* 0 -- unused; BASIC counts colors from 1 */ + { 0x00, 0x00, 0x00, 0xff }, /* 1 -- black */ + { 0xff, 0xff, 0xff, 0xff }, /* 2 -- white */ + { 0x88, 0x39, 0x32, 0xff }, /* 3 -- red */ + { 0x67, 0xb6, 0xbd, 0xff }, /* 4 -- cyan */ + { 0x8b, 0x3f, 0x96, 0xff }, /* 5 -- purple */ + { 0x55, 0xa0, 0x49, 0xff }, /* 6 -- green */ + { 0x40, 0x31, 0x8d, 0xff }, /* 7 -- blue */ + { 0xbf, 0xce, 0x72, 0xff }, /* 8 -- yellow */ + { 0x8b, 0x54, 0x29, 0xff }, /* 9 -- orange */ + { 0x57, 0x42, 0x00, 0xff }, /* 10 -- brown */ + { 0xb8, 0x69, 0x62, 0xff }, /* 11 -- light red */ + { 0x50, 0x50, 0x50, 0xff }, /* 12 -- dark grey */ + { 0x78, 0x78, 0x78, 0xff }, /* 13 -- medium grey */ + { 0x94, 0xe0, 0x89, 0xff }, /* 14 -- light green */ + { 0x78, 0x69, 0xc4, 0xff }, /* 15 -- light blue */ + { 0x9f, 0x9f, 0x9f, 0xff } /* 16 -- light grey */ +}; + +akerr_ErrorContext *akbasic_graphics_palette(int index, akbasic_Color *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER, + "NULL destination in palette"); + FAIL_ZERO_RETURN(errctx, (index >= 1 && index <= 16), AKBASIC_ERR_BOUNDS, + "Color index %d out of range (1 to 16)", index); + *dest = PALETTE[index]; + SUCCEED_RETURN(errctx); +} diff --git a/src/runtime.c b/src/runtime.c index 737da7f..06501a7 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -152,6 +152,41 @@ akerr_ErrorContext *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink SUCCEED_RETURN(errctx); } +akerr_ErrorContext *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_GraphicsBackend *graphics, akbasic_AudioBackend *audio, akbasic_InputBackend *input) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL runtime in set_devices"); + + /* + * No validation of the records themselves. A backend with a NULL entry point + * is caught at the call site by the verb that needs it, which can say which + * verb wanted what -- checking every pointer here would only be able to say + * that something, somewhere, was incomplete. + */ + obj->graphics = graphics; + obj->audio = audio; + obj->input = input; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_runtime_settime(akbasic_Runtime *obj, int64_t timems) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL runtime in settime"); + + /* + * Deliberately not rejecting a time that moves backwards. A host is free to + * drive this from a paused, scrubbed or replayed clock, and the only thing + * that happens is a note holding longer than it asked to. + */ + obj->timems = timems; + SUCCEED_RETURN(errctx); +} + /* ---------------------------------------------------------------- output -- */ akerr_ErrorContext *akbasic_runtime_write(akbasic_Runtime *obj, const char *text) diff --git a/tests/devices.c b/tests/devices.c new file mode 100644 index 0000000..22c7822 --- /dev/null +++ b/tests/devices.c @@ -0,0 +1,171 @@ +/** + * @file devices.c + * @brief Tests the device backend records, the palette table and the host clock. + * + * These are the pieces groups G, I and E are built on. Nothing here runs a BASIC + * verb -- that is what the per-group suites do -- it establishes that a runtime + * can be given backends, can be given none, and reports the time it was handed. + */ + +#include + +#include +#include +#include + +#include "harness.h" +#include "mockdevice.h" +#include "testutil.h" + +/** + * @brief A runtime with no backends attached must still come up and still print. + * + * This is the standalone driver's situation and the default for an embedding + * host that has not opted in, so it is the case that has to keep working. + */ +static void test_no_devices_is_normal(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE(HARNESS_RUNTIME.graphics == NULL, "graphics backend should start NULL"); + TEST_REQUIRE(HARNESS_RUNTIME.audio == NULL, "audio backend should start NULL"); + TEST_REQUIRE(HARNESS_RUNTIME.input == NULL, "input backend should start NULL"); + TEST_REQUIRE_INT(HARNESS_RUNTIME.timems, 0); + + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT \"HI\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "HI\n"); + harness_stop(); +} + +/** @brief Attaching backends stores them; attaching NULLs takes them away again. */ +static void test_set_devices(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + + TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, + &MOCK_AUDIO, &MOCK_INPUT)); + TEST_REQUIRE(HARNESS_RUNTIME.graphics == &MOCK_GRAPHICS, "graphics backend was not stored"); + TEST_REQUIRE(HARNESS_RUNTIME.audio == &MOCK_AUDIO, "audio backend was not stored"); + TEST_REQUIRE(HARNESS_RUNTIME.input == &MOCK_INPUT, "input backend was not stored"); + + /* Withholding a capability is spelled NULL, and must actually detach. */ + TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL)); + TEST_REQUIRE(HARNESS_RUNTIME.graphics == &MOCK_GRAPHICS, "graphics backend should have stayed"); + TEST_REQUIRE(HARNESS_RUNTIME.audio == NULL, "audio backend should have been detached"); + TEST_REQUIRE(HARNESS_RUNTIME.input == NULL, "input backend should have been detached"); + + TEST_REQUIRE_STATUS(akbasic_runtime_set_devices(NULL, NULL, NULL, NULL), AKERR_NULLPOINTER); + harness_stop(); +} + +/** @brief The clock is stored verbatim, including a value that moves backwards. */ +static void test_settime(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + + TEST_REQUIRE_OK(akbasic_runtime_settime(&HARNESS_RUNTIME, 1000)); + TEST_REQUIRE_INT(HARNESS_RUNTIME.timems, 1000); + + /* + * A host driving a paused or scrubbed clock is allowed. Rejecting it would + * make the interpreter an authority on the host's own time, which it is not. + */ + TEST_REQUIRE_OK(akbasic_runtime_settime(&HARNESS_RUNTIME, 400)); + TEST_REQUIRE_INT(HARNESS_RUNTIME.timems, 400); + + TEST_REQUIRE_STATUS(akbasic_runtime_settime(NULL, 0), AKERR_NULLPOINTER); + harness_stop(); +} + +/** @brief The palette covers 1 to 16 and refuses everything else. */ +static void test_palette(void) +{ + akbasic_Color color; + int index = 0; + + memset(&color, 0, sizeof(color)); + + for ( index = 1; index <= 16; index++ ) { + TEST_REQUIRE_OK(akbasic_graphics_palette(index, &color)); + TEST_REQUIRE_INT(color.a, 0xff); + } + + /* Two entries pinned by value, so a shifted table is a failure and not a shrug. */ + TEST_REQUIRE_OK(akbasic_graphics_palette(1, &color)); + TEST_REQUIRE(color.r == 0x00 && color.g == 0x00 && color.b == 0x00, + "color 1 should be black, got %02x%02x%02x", color.r, color.g, color.b); + TEST_REQUIRE_OK(akbasic_graphics_palette(2, &color)); + TEST_REQUIRE(color.r == 0xff && color.g == 0xff && color.b == 0xff, + "color 2 should be white, got %02x%02x%02x", color.r, color.g, color.b); + + /* + * BASIC counts from 1. Zero is the off-by-one somebody writes when they + * forget that, and it has to be refused rather than quietly return slot 0. + */ + TEST_REQUIRE_STATUS(akbasic_graphics_palette(0, &color), AKBASIC_ERR_BOUNDS); + TEST_REQUIRE_STATUS(akbasic_graphics_palette(17, &color), AKBASIC_ERR_BOUNDS); + TEST_REQUIRE_STATUS(akbasic_graphics_palette(-1, &color), AKBASIC_ERR_BOUNDS); + TEST_REQUIRE_STATUS(akbasic_graphics_palette(1, NULL), AKERR_NULLPOINTER); +} + +/** @brief The mock backends themselves record what they were told. */ +static void test_mock_records(void) +{ + akbasic_Color red = { 0xff, 0x00, 0x00, 0xff }; + bool active = false; + int handle = -1; + int keycode = 0; + bool available = false; + + mock_devices_init(); + + TEST_REQUIRE_OK(MOCK_GRAPHICS.point(&MOCK_GRAPHICS, 10.0, 20.0, red)); + TEST_REQUIRE_OK(MOCK_GRAPHICS.line(&MOCK_GRAPHICS, 0.0, 0.0, 5.0, 5.0, red)); + TEST_REQUIRE_STR(MOCK.log, "point 10.0,20.0 #ff0000\nline 0.0,0.0-5.0,5.0 #ff0000\n"); + + /* A saved shape yields a handle, and only a handle that exists can be pasted. */ + TEST_REQUIRE_OK(MOCK_GRAPHICS.save_shape(&MOCK_GRAPHICS, 0, 0, 8, 8, &handle)); + TEST_REQUIRE_INT(handle, 0); + TEST_REQUIRE_OK(MOCK_GRAPHICS.paste_shape(&MOCK_GRAPHICS, handle, 16.0, 16.0)); + TEST_REQUIRE_STATUS(MOCK_GRAPHICS.paste_shape(&MOCK_GRAPHICS, 99, 0.0, 0.0), AKBASIC_ERR_BOUNDS); + + /* The exhaustion switch is what lets the PAINT test reach the partial-fill path. */ + MOCK.paint_exhausts = true; + TEST_REQUIRE_STATUS(MOCK_GRAPHICS.paint(&MOCK_GRAPHICS, 1, 1, red), AKERR_OUTOFBOUNDS); + MOCK.paint_exhausts = false; + TEST_REQUIRE_OK(MOCK_GRAPHICS.paint(&MOCK_GRAPHICS, 1, 1, red)); + + TEST_REQUIRE_OK(MOCK_AUDIO.tone(&MOCK_AUDIO, 0, 440.0, 250)); + TEST_REQUIRE_OK(MOCK_AUDIO.voice_active(&MOCK_AUDIO, 0, &active)); + TEST_REQUIRE(active, "voice 0 should be sounding after a tone"); + TEST_REQUIRE_OK(MOCK_AUDIO.stop(&MOCK_AUDIO, 0)); + TEST_REQUIRE_OK(MOCK_AUDIO.voice_active(&MOCK_AUDIO, 0, &active)); + TEST_REQUIRE(!active, "voice 0 should be silent after a stop"); + TEST_REQUIRE_STATUS(MOCK_AUDIO.tone(&MOCK_AUDIO, AKBASIC_AUDIO_VOICES, 440.0, 1), + AKBASIC_ERR_BOUNDS); + + /* An empty key buffer is success with available false, never an error. */ + TEST_REQUIRE_OK(MOCK_INPUT.poll_key(&MOCK_INPUT, &keycode, &available)); + TEST_REQUIRE(!available, "an empty mock buffer should report nothing available"); + + mock_push_keys("AB"); + TEST_REQUIRE_OK(MOCK_INPUT.poll_key(&MOCK_INPUT, &keycode, &available)); + TEST_REQUIRE(available, "a primed mock buffer should report a key"); + TEST_REQUIRE_INT(keycode, 'A'); + TEST_REQUIRE_OK(MOCK_INPUT.poll_key(&MOCK_INPUT, &keycode, &available)); + TEST_REQUIRE_INT(keycode, 'B'); + TEST_REQUIRE_OK(MOCK_INPUT.poll_key(&MOCK_INPUT, &keycode, &available)); + TEST_REQUIRE(!available, "the mock buffer should drain to empty"); +} + +int main(void) +{ + test_no_devices_is_normal(); + test_set_devices(); + test_settime(); + test_palette(); + test_mock_records(); + return akbasic_test_failures; +} diff --git a/tests/error_codes.c b/tests/error_codes.c index 3b9ed84..72b77a6 100644 --- a/tests/error_codes.c +++ b/tests/error_codes.c @@ -22,7 +22,7 @@ int main(void) * "Unknown Error" in every stack trace that carries it, which nothing else * in the suite would notice. */ - for ( code = AKBASIC_ERR_SYNTAX; code <= AKBASIC_ERR_STATE; code++ ) { + for ( code = AKBASIC_ERR_SYNTAX; code < AKBASIC_ERR_LAST; code++ ) { const char *name = akerr_name_for_status(code, NULL); TEST_REQUIRE(name != NULL, "status %d has a NULL name", code); TEST_REQUIRE(strcmp(name, "Unknown Error") != 0, @@ -31,6 +31,16 @@ int main(void) TEST_REQUIRE_STR(akerr_name_for_status(AKBASIC_ERR_SYNTAX, NULL), "Syntax Error"); TEST_REQUIRE_STR(akerr_name_for_status(AKBASIC_ERR_STATE, NULL), "State Error"); + TEST_REQUIRE_STR(akerr_name_for_status(AKBASIC_ERR_DEVICE, NULL), "Device Error"); + + /* + * The sentinel must stay inside the band it terminates. If somebody ever adds + * 250 codes, the loop above walks straight out of our reservation and starts + * asserting on names libakgl or nobody owns. + */ + TEST_REQUIRE(AKBASIC_ERR_LAST < AKBASIC_ERR_LIMIT, + "AKBASIC_ERR_LAST %d has run past the reserved band ending at %d", + AKBASIC_ERR_LAST, AKBASIC_ERR_LIMIT); /* * The assertion that proves the range is actually ours rather than merely diff --git a/tests/mockdevice.h b/tests/mockdevice.h new file mode 100644 index 0000000..9506ce3 --- /dev/null +++ b/tests/mockdevice.h @@ -0,0 +1,333 @@ +/** + * @file mockdevice.h + * @brief Recording graphics, audio and input backends for the device tests. + * + * The graphics and audio verbs produce no output a golden file can compare, so + * the assertions have to be about what reached the backend. These record every + * call as a formatted line, which a test compares against the sequence it + * expected -- what was drawn, in what order, in what color. + * + * Nothing here needs SDL, which is the point of the backend records in the first + * place: the whole of groups G, I and E is testable in the default build. + */ + +#ifndef _AKBASIC_TEST_MOCKDEVICE_H_ +#define _AKBASIC_TEST_MOCKDEVICE_H_ + +#include +#include +#include + +#include + +#include +#include +#include +#include + +/** @brief Room for the call log. Longer than any test needs; overflow truncates loudly. */ +#define MOCK_LOG_SIZE 8192 +/** @brief How many keystrokes mock_input can be primed with. */ +#define MOCK_MAX_KEYS 32 +/** @brief How many regions mock_graphics will hand out handles for. */ +#define MOCK_MAX_SHAPES 8 + +/** @brief State shared by all three mock backends, so one log records the lot. */ +typedef struct +{ + char log[MOCK_LOG_SIZE]; + size_t used; + bool overflowed; + + /* Graphics */ + int shapes; /* handles handed out so far */ + bool paint_exhausts; /* when true, paint reports a partial fill */ + + /* Audio */ + bool voice_active[AKBASIC_AUDIO_VOICES]; + + /* Input */ + int keys[MOCK_MAX_KEYS]; + int keycount; + int keynext; +} akbasic_MockDevice; + +static akbasic_MockDevice MOCK; +static akbasic_GraphicsBackend MOCK_GRAPHICS; +static akbasic_AudioBackend MOCK_AUDIO; +static akbasic_InputBackend MOCK_INPUT; + +/** + * @brief Append one formatted call to the log. + * + * Deliberately not an akerr_ErrorContext * function. It is called from inside + * the vtable entry points, and a test whose log overflowed should say so through + * the assertion on the log contents rather than by raising an error that the + * verb under test would then report as a device failure. + */ +__attribute__((format(printf, 1, 2))) +static void mock_log(const char *format, ...) +{ + va_list args; + int written = 0; + + if ( MOCK.used >= sizeof(MOCK.log) - 1 ) { + MOCK.overflowed = true; + return; + } + va_start(args, format); + written = vsnprintf(MOCK.log + MOCK.used, sizeof(MOCK.log) - MOCK.used, format, args); + va_end(args); + if ( written < 0 || (size_t)written >= sizeof(MOCK.log) - MOCK.used ) { + MOCK.overflowed = true; + MOCK.used = sizeof(MOCK.log) - 1; + return; + } + MOCK.used += (size_t)written; +} + +/* --------------------------------------------------------------- graphics -- */ + +static akerr_ErrorContext *mock_point(akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("point %.1f,%.1f #%02x%02x%02x\n", x, y, color.r, color.g, color.b); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_line(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("line %.1f,%.1f-%.1f,%.1f #%02x%02x%02x\n", x1, y1, x2, y2, color.r, color.g, color.b); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("rect %.1f,%.1f-%.1f,%.1f #%02x%02x%02x\n", x1, y1, x2, y2, color.r, color.g, color.b); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_filled_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("fillrect %.1f,%.1f-%.1f,%.1f #%02x%02x%02x\n", x1, y1, x2, y2, color.r, color.g, color.b); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_paint(akbasic_GraphicsBackend *self, int x, int y, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("paint %d,%d #%02x%02x%02x\n", x, y, color.r, color.g, color.b); + /* + * Stands in for the akgl flood fill running out of its fixed span stack, + * which reports AKERR_OUTOFBOUNDS having filled part of the region. PAINT has + * to surface that rather than treat it as success. + */ + FAIL_ZERO_RETURN(errctx, (!MOCK.paint_exhausts), AKERR_OUTOFBOUNDS, + "mock span stack exhausted"); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_clear(akbasic_GraphicsBackend *self, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("clear #%02x%02x%02x\n", color.r, color.g, color.b); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_save_shape(akbasic_GraphicsBackend *self, int x1, int y1, int x2, int y2, int *handle) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (handle != NULL), AKERR_NULLPOINTER, "NULL handle in save_shape"); + FAIL_ZERO_RETURN(errctx, (MOCK.shapes < MOCK_MAX_SHAPES), AKBASIC_ERR_DEVICE, + "mock shape pool exhausted"); + *handle = MOCK.shapes; + MOCK.shapes += 1; + mock_log("save %d,%d-%d,%d -> %d\n", x1, y1, x2, y2, *handle); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_paste_shape(akbasic_GraphicsBackend *self, int handle, double x, double y) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (handle >= 0 && handle < MOCK.shapes), AKBASIC_ERR_BOUNDS, + "no such mock shape %d", handle); + mock_log("paste %d at %.1f,%.1f\n", handle, x, y); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_free_shapes(akbasic_GraphicsBackend *self) +{ + PREPARE_ERROR(errctx); + (void)self; + MOCK.shapes = 0; + mock_log("freeshapes\n"); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------------ audio -- */ + +static akerr_ErrorContext *mock_tone(akbasic_AudioBackend *self, int voice, double hz, int ms) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS, + "mock voice %d out of range", voice); + MOCK.voice_active[voice] = true; + mock_log("tone v%d %.1fhz %dms\n", voice, hz, ms); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_audio_stop(akbasic_AudioBackend *self, int voice) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS, + "mock voice %d out of range", voice); + MOCK.voice_active[voice] = false; + mock_log("stop v%d\n", voice); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_waveform(akbasic_AudioBackend *self, int voice, akbasic_Waveform waveform) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS, + "mock voice %d out of range", voice); + mock_log("wave v%d %d\n", voice, (int)waveform); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_envelope(akbasic_AudioBackend *self, int voice, int attack, int decay, double sustain, int release) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS, + "mock voice %d out of range", voice); + mock_log("env v%d %d/%d/%.2f/%d\n", voice, attack, decay, sustain, release); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_volume(akbasic_AudioBackend *self, double level) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("vol %.2f\n", level); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_voice_active(akbasic_AudioBackend *self, int voice, bool *active) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (active != NULL), AKERR_NULLPOINTER, "NULL active in voice_active"); + FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS, + "mock voice %d out of range", voice); + *active = MOCK.voice_active[voice]; + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------------ input -- */ + +static akerr_ErrorContext *mock_poll_key(akbasic_InputBackend *self, int *keycode, bool *available) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (keycode != NULL && available != NULL), AKERR_NULLPOINTER, + "NULL argument in poll_key"); + if ( MOCK.keynext >= MOCK.keycount ) { + /* An empty buffer is success, not an error -- see input.h. */ + *available = false; + *keycode = 0; + SUCCEED_RETURN(errctx); + } + *keycode = MOCK.keys[MOCK.keynext]; + *available = true; + MOCK.keynext += 1; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_flush_keys(akbasic_InputBackend *self) +{ + PREPARE_ERROR(errctx); + (void)self; + MOCK.keynext = MOCK.keycount; + mock_log("flushkeys\n"); + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- fixture -- */ + +/** @brief Reset the recorder and populate all three vtables. */ +__attribute__((unused)) +static void mock_devices_init(void) +{ + memset(&MOCK, 0, sizeof(MOCK)); + + MOCK_GRAPHICS.self = &MOCK; + MOCK_GRAPHICS.point = mock_point; + MOCK_GRAPHICS.line = mock_line; + MOCK_GRAPHICS.rect = mock_rect; + MOCK_GRAPHICS.filled_rect = mock_filled_rect; + MOCK_GRAPHICS.paint = mock_paint; + MOCK_GRAPHICS.clear = mock_clear; + MOCK_GRAPHICS.save_shape = mock_save_shape; + MOCK_GRAPHICS.paste_shape = mock_paste_shape; + MOCK_GRAPHICS.free_shapes = mock_free_shapes; + + MOCK_AUDIO.self = &MOCK; + MOCK_AUDIO.tone = mock_tone; + MOCK_AUDIO.stop = mock_audio_stop; + MOCK_AUDIO.waveform = mock_waveform; + MOCK_AUDIO.envelope = mock_envelope; + MOCK_AUDIO.volume = mock_volume; + MOCK_AUDIO.voice_active = mock_voice_active; + + MOCK_INPUT.self = &MOCK; + MOCK_INPUT.poll_key = mock_poll_key; + MOCK_INPUT.flush_keys = mock_flush_keys; +} + +/** @brief Prime the input backend with keystrokes GET will drain in order. */ +__attribute__((unused)) +static void mock_push_keys(const char *keys) +{ + size_t i = 0; + + MOCK.keycount = 0; + MOCK.keynext = 0; + for ( i = 0; keys != NULL && keys[i] != '\0' && MOCK.keycount < MOCK_MAX_KEYS; i++ ) { + MOCK.keys[MOCK.keycount] = (int)(unsigned char)keys[i]; + MOCK.keycount += 1; + } +} + +/** @brief Forget everything logged so far, keeping the primed state. */ +__attribute__((unused)) +static void mock_log_reset(void) +{ + memset(MOCK.log, 0, sizeof(MOCK.log)); + MOCK.used = 0; + MOCK.overflowed = false; +} + +#endif // _AKBASIC_TEST_MOCKDEVICE_H_