Reach hardware through backend records, and take the time from the host
Groups G, I and E are unblocked but cannot be written yet: the core library is free of SDL and builds with no libakgl present, so a graphics verb cannot call akgl_draw_* and a sound verb cannot call akgl_audio_*. This adds what they call instead. Three records of function pointers -- akbasic_GraphicsBackend, _AudioBackend and _InputBackend -- in the same shape as akbasic_TextSink, and the same shape libakgl uses for akgl_RenderBackend. akbasic_runtime_set_devices() attaches any subset; all three may be NULL and that is the standalone driver's normal state, so a runtime with no backends still comes up and still prints. A verb that needs one it was not given raises the new AKBASIC_ERR_DEVICE rather than dereferencing a NULL vtable. Two decisions worth stating. The graphics record has no circle entry point: BASIC 7.0's CIRCLE takes two radii, an arc range, a rotation and a degree increment, which makes it a polygon by definition, so it will be built from line calls rather than from akgl_draw_circle. And coordinates are double rather than an integer pixel address, because SCALE makes them fractional and rounding at each verb rather than once at the backend accumulates drift along a polyline. akbasic_runtime_settime() is how SOUND, PLAY and TEMPO get a clock without the library reading one. Section 1.6 forbids blocking or owning a loop, so the caller that owns the frame owns the time -- which is what libakgl already does, since akgl_actor_logic_changeframe takes curtimems as an argument. Left unset it is zero and every duration expires immediately: audible, but never a hang. AKBASIC_ERR_LAST is a sentinel rather than a status, so tests/error_codes.c walks every code looking for an unnamed one without anybody remembering to widen the loop when a code is added. tests/mockdevice.h records every backend call as a formatted line. The graphics and audio verbs emit nothing a golden file can compare, so that log is where their assertions have to live -- and since it needs no SDL, the whole of groups G, I and E stays testable in the default build. 62/62 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
include/akbasic/audio.h
Normal file
71
include/akbasic/audio.h
Normal file
@@ -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 <akerror.h>
|
||||
|
||||
#include <akbasic/types.h>
|
||||
|
||||
/** @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_
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
105
include/akbasic/graphics.h
Normal file
105
include/akbasic/graphics.h
Normal file
@@ -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 <akerror.h>
|
||||
|
||||
#include <akbasic/types.h>
|
||||
|
||||
/**
|
||||
* @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_
|
||||
42
include/akbasic/input.h
Normal file
42
include/akbasic/input.h
Normal file
@@ -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 <akerror.h>
|
||||
|
||||
#include <akbasic/types.h>
|
||||
|
||||
/**
|
||||
* @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_
|
||||
@@ -18,8 +18,11 @@
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akbasic/audio.h>
|
||||
#include <akbasic/environment.h>
|
||||
#include <akbasic/grammar.h>
|
||||
#include <akbasic/graphics.h>
|
||||
#include <akbasic/input.h>
|
||||
#include <akbasic/sink.h>
|
||||
#include <akbasic/types.h>
|
||||
#include <akbasic/value.h>
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user