Draw into the whole window, not its top-left 320x200

The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.

akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.

SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.

SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.

The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:35:20 -04:00
parent 23ccb66f69
commit 5c5bf63356
15 changed files with 445 additions and 49 deletions

67
TODO.md
View File

@@ -800,13 +800,43 @@ behaviour to port. They matter to somebody typing in a listing out of a C128 man
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.
means anything against a host's renderer, whose size the interpreter 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. The mode is readable as `RGR(0)`, which
is 7.0's whole `RGR` and the one field of ours that is.
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.
18. **A coordinate reaching a backend is a device pixel, and the whole of the host's window
is reachable from BASIC.** This used to read "coordinates are BASIC's 320x200 space and
scaling to the window is the host's job", on the reasoning that the interpreter cannot
ask the renderer how big it is without owning it. **That reasoning was wrong twice over**,
which is what §8 item 9 came to say.
It was wrong about the mechanism: not owning a thing is no bar to *asking* it, and the
backend record is how everything else here asks. `akbasic_GraphicsBackend` grew a `size`
entry point, `require_graphics()` calls it before every verb that draws -- so a resized
window is honoured between two statements rather than at attach -- and 320x200 became
the fallback for a backend that leaves `size` NULL. It is the record's one optional
entry point, so a host written against the old header keeps working and gets exactly the
behaviour it used to get.
And it was wrong about the description: nothing ever stretched anything. With `SCALE`
off a coordinate was passed straight to `akgl_draw_*` as a pixel address, so an 800x600
window drew a C128 listing into its top-left 320x200 corner and left the rest unused.
The documentation described a coordinate transform that did not exist.
**What changed observably** is `SCALE`, which now maps user coordinates onto the device
rather than onto the constants, and `RGR(1)` / `RGR(2)`, which report the drawing
surface's width and height so a program can use a window whose size it did not choose.
A C128 listing draws in the corner as it always did; `SCALE 1, 319, 199` gives it the
whole window.
**One fix rode along, in the same line of code and too small to defer.** `SCALE` mapped
`xmax` onto the *width*, which put the user space's far corner one pixel past the
surface: `SCALE 1, 319, 199` then `DRAW 1, 319, 199` drew nothing at all. It maps onto
the last pixel now, which is what makes that sentence above true rather than nearly
true. `tests/graphics_verbs.c` and `tests/akgl_backends.c`, the latter against a
128x128 target chosen because it is *smaller* than the old constants -- a `SCALE` still
dividing by them misses it entirely rather than landing somewhere plausible.
19. **`PLAY` does not block.** On a C128 `PLAY` holds the program until the last note ends.
§1.6 forbids that outright, so `PLAY` parses the string into a fixed queue and returns;
@@ -1158,11 +1188,19 @@ deviations from the reference's *program*: `main.go` and the SDL half of
last one in the source until one of them runs. The scan is textual on purpose: parsing every
line up front would raise on lines the program would never have reached.
33. **Sprite coordinates are BASIC's 320×200, not the VIC-II's 0511 by 0255.** `MOVSPR` takes
33. **Sprite coordinates are device pixels, not the VIC-II's 0511 by 0255.** `MOVSPR` takes
the same coordinate space `DRAW` does. A C128's sprite coordinates are the raster's, offset
so that (24, 50) is the top-left of the visible screen; reproducing that would give the
language two coordinate systems and make `MOVSPR 1, 0, 0` put a sprite off-screen. Consistent
with deviation 18.
with deviation 18, which is where "the same space `DRAW` uses" is defined — and it moved,
so this one moved with it.
**`SCALE` deliberately does not apply to sprites.** It is a graphics-verb transform and
the sprite verbs do not call it, which was true before deviation 18 was rewritten and is
worth writing down now that the two spaces can differ: with `SCALE 1, 319, 199` on, a
`DRAW` at 160,100 and a `MOVSPR` to 160,100 land in different places. Arguably they
should agree; not changed here because it is a decision about what `SCALE` means rather
than a slip, and it wants its own commit.
34. **`MOVSPR`'s speed unit is ours.** BASIC 7.0 documents speed 015 with 15 fastest and says
nothing about what a unit is worth. Here one unit is
@@ -1757,9 +1795,18 @@ What remains, in priority order:
almost everything links against it), which is why CI runs `src/symtab.c` instead and the
whole-tree run is a local `cmake --build build --target mutation`.
9. Use the actual full SDL window for graphics operation width
9. ~~**Use the actual full SDL window for graphics operations.**~~ **Done**, and the premise
turned out to be half wrong in a way worth keeping. The documentation did say coordinates
are 320x200 whatever the window is — but nothing ever stretched them: with `SCALE` off a
coordinate went straight to `akgl_draw_*` as a pixel address, so an 800x600 window drew a
listing into its top-left corner and left the rest unused. The described transform did not
exist.
The current documentation states that graphics operations always use a 320x200 space regardless of the window's size. These verbs should be able to access the entirety of the SDL window to which they are bound.
`akbasic_GraphicsBackend` grew an optional `size` entry point, `require_graphics()` asks it
before every verb that draws so a resized window is honoured between statements, `SCALE`
maps onto the answer instead of onto the constants, and `RGR(1)` / `RGR(2)` let a program
read the size. 320x200 is now the fallback for a backend that will not answer. Deviation 18
in §5 has the whole of it, including the off-by-one it fixed on the way.
11. Error codes are undefined for the user

View File

@@ -14,10 +14,35 @@ verb refuses by name:
## The coordinate space
Drawing coordinates are **320 by 200**, with (0, 0) at the top left — BASIC 7.0's
hi-res screen. That is true whatever size the host's window is; stretching to fit is
the host's business, not the program's. `SCALE` maps a different range onto the same
screen.
**A drawing coordinate is a pixel of the host's window**, with (0, 0) at the top left.
On the standalone interpreter's 800 by 600 window, `DRAW 1, 799, 599` lands on the
bottom-right pixel and everything in between is reachable. A game embedding the
interpreter gets whatever size its own renderer is.
`RGR` is how a program finds out:
```basic requires=akgl
10 PRINT "THE SCREEN IS"
20 PRINT RGR(1)
30 PRINT "BY"
40 PRINT RGR(2)
50 DRAW 1, 0, 0 TO RGR(1) - 1, RGR(2) - 1
```
Subtracting one is not a wart, it is the last pixel: a window `RGR(1)` wide has
columns 0 through `RGR(1) - 1`.
**A C128 listing assumes 320 by 200 and will draw in the top-left corner.** Give it
the whole window by naming the space it was written for:
```basic requires=akgl
10 SCALE 1, 319, 199
20 BOX 1, 0, 0, 319, 199
```
That box is now the border of the window whatever size the window is. When no device
answers the size question at all, 320 by 200 is what the interpreter assumes — the
space a C128 listing was written for is the right thing to fall back to.
## Colour
@@ -90,7 +115,18 @@ coordinates finishes.
```
Turns on user coordinates and gives their maxima. With it on, your coordinates are
mapped onto the 320 by 200 screen. `SCALE 0` turns it off.
mapped onto the drawing surface: 0 is the first pixel and the maximum you gave is the
*last* one, so `DRAW 1, 1023, 1023` above reaches the bottom-right corner rather than
missing it by a pixel. `SCALE 1` on its own uses 7.0's 1023 by 1023. `SCALE 0` turns
it off and coordinates go back to being window pixels.
### RGR
`RGR(0)` is the current `GRAPHIC` mode. `RGR(1)` and `RGR(2)` are the drawing
surface's width and height in pixels — those two are ours rather than 7.0's, and they
are what a program needs to use a window whose size it did not choose. All three
refuse when there is no graphics device, except `RGR(0)`, which is a mode this
interpreter recorded rather than a screen it has to go and measure.
### WIDTH

View File

@@ -84,8 +84,10 @@ the continuous form, which is a trap worth remembering.
The continuous form does not move anything on the statement itself. The sprite moves as
the program runs, paced by the host's clock. Speed 0 stops it.
Coordinates are the same 320 by 200 space the drawing verbs use, not the VIC-II's
raster coordinates.
Coordinates are the same window pixels the drawing verbs use, not the VIC-II's raster
coordinates — see Chapter 6, and `RGR(1)` and `RGR(2)` for how big the window is.
`SCALE` does not apply to them: a sprite is positioned in device pixels whatever the
drawing verbs are doing.
## Collision

View File

@@ -25,6 +25,7 @@ so a call with the wrong number is a syntax error rather than a surprise.
| `POINTER` | 1 | `POINTER(V)` | The address of a variable's value. |
| `POINTERVAR` | 1 | `POINTERVAR(V)` | The address of the variable structure itself, metadata included. |
| `RAD` | 1 | `RAD(n)` | Degrees converted to radians. |
| `RGR` | 1 | `RGR(f)` | The `GRAPHIC` mode (0), or the drawing surface's width (1) or height (2) in pixels. |
| `RIGHT` | 2 | `RIGHT(A$, n)` | The rightmost `n` characters. Clamped. |
| `RSPCOLOR` | 1 | `RSPCOLOR(n)` | One of `SPRCOLOR`'s two shared registers, 1 or 2. |
| `RSPPOS` | 2 | `RSPPOS(n, f)` | A sprite's x (0), y (1) or speed (2). |

View File

@@ -89,7 +89,10 @@ interpreter's error code, which bears no relation to a Commodore error number. P
## Graphics
- **Coordinates are always 320 by 200**, whatever the window size.
- **A coordinate is a window pixel, not one of 320 by 200.** A C128 listing therefore
draws in the top-left corner of a larger window; `SCALE 1, 319, 199` gives it the
whole window back. `RGR(1)` and `RGR(2)` report the size, and are ours rather than
7.0's — where 7.0's `RGR` takes only field 0, the `GRAPHIC` mode.
- **`SSHAPE` puts a handle in the string, not the pixels.** You can pass it to `GSHAPE`
and `SPRSAV`; you cannot save it or take its `LEN`.
- **`WIDTH` is emulated** by drawing parallel passes.
@@ -107,7 +110,8 @@ interpreter's error code, which bears no relation to a Commodore error number. P
## Sprites
- **Coordinates are the 320 by 200 drawing space**, not the VIC-II's raster space.
- **Coordinates are window pixels**, not the VIC-II's raster space, and `SCALE` does
not apply to them.
- **`SPRSAV` takes an integer array**, not a string, for the data form — a string here
cannot hold a zero byte. It also takes an **image file path**, which a C128 cannot.
- **A sprite loaded from a file keeps the image's own size**, not 24 by 21.

View File

@@ -24,12 +24,18 @@
#include <akbasic/types.h>
/**
* @brief The coordinate space the backend receives, in C128 hi-res pixels.
* @brief The coordinate space assumed when the device will not say how big it is.
*
* 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.
* A coordinate reaching the backend is a *device pixel*: `DRAW 1, 799, 599` lands
* at the bottom-right of an 800x600 window, and every pixel of that window is
* addressable from BASIC. The interpreter still does not own the renderer -- it
* asks, through akbasic_GraphicsBackend::size(), and the backend that does own
* one answers.
*
* These constants are what is assumed when nobody answers: a backend that leaves
* `size` NULL, or reports a nonsense size, gets BASIC 7.0's 320x200 hi-res
* screen. That is the right fallback rather than a guess, because it is the space
* a C128 listing was written for.
*/
#define AKBASIC_GRAPHICS_WIDTH 320
#define AKBASIC_GRAPHICS_HEIGHT 200
@@ -70,6 +76,8 @@ typedef struct
int linewidth; /* WIDTH: 1 or 2 pixels per drawn line */
double xmax; /* user-coordinate maxima SCALE maps from */
double ymax;
int devwidth; /* what the device last said it is, in pixels; */
int devheight; /* the AKBASIC_GRAPHICS_* fallback until then */
} akbasic_GraphicsState;
/**
@@ -89,6 +97,21 @@ typedef struct akbasic_GraphicsBackend
{
void *self;
/**
* How big the drawing surface is, in pixels.
*
* **Optional, and the only optional entry point in the record.** It was
* added after the rest, so a host that filled one of these in by hand
* before it existed leaves it NULL and keeps the 320x200 fallback rather
* than crashing; and a backend drawing somewhere with no meaningful size
* has an honest way to say so. Everything else here is required.
*
* Called before each verb draws rather than once at attach, because a
* window is resizable and a script that runs for an hour should not be
* drawing to the size the window was when it started.
*/
akerr_ErrorContext AKERR_NOIGNORE *(*size)(struct akbasic_GraphicsBackend *self, int *width, int *height);
/** 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. */

View File

@@ -56,10 +56,10 @@
*
* `MOVSPR n, angle # speed` takes 0-15 on a C128 and the reference manual does
* not say what a unit is in pixels; it says 15 is fastest and 0 stops. So the
* mapping is ours: one unit is #AKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND pixels
* per second in BASIC's 320x200 space, which puts speed 15 at four seconds to
* cross the screen. Recorded as a deviation in TODO.md section 5 rather than
* presented as fidelity.
* mapping is ours: one unit is #AKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND device
* pixels per second, which puts speed 15 at four seconds to cross a 320-pixel
* screen and proportionally longer across a wider one. Recorded as a deviation
* in TODO.md section 5 rather than presented as fidelity.
*/
#define AKBASIC_SPRITE_MAX_SPEED 15
/** @brief Pixels per second one unit of MOVSPR speed is worth. See #AKBASIC_SPRITE_MAX_SPEED. */
@@ -83,7 +83,7 @@ typedef struct
bool yexpand;
bool multicolour;
int colorindex; /* 1-16, the sprite's own colour */
double x; /* position in BASIC's 320x200 space */
double x; /* position in device pixels; see graphics.h */
double y;
double angle; /* MOVSPR's continuous motion: degrees */
int speed; /* clockwise from vertical, and 0-15 */
@@ -159,7 +159,7 @@ typedef struct akbasic_SpriteBackend
akerr_ErrorContext AKERR_NOIGNORE *(*define_file)(struct akbasic_SpriteBackend *self, int n, const char *path, const char *root);
/** Show or hide sprite @p n. */
akerr_ErrorContext AKERR_NOIGNORE *(*show)(struct akbasic_SpriteBackend *self, int n, bool visible);
/** Put sprite @p n at (@p x, @p y) in BASIC's 320x200 space. */
/** Put sprite @p n at (@p x, @p y), in device pixels. */
akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akbasic_SpriteBackend *self, int n, double x, double y);
/**
* Set sprite @p n's own colour, its expansion bits and its priority.

View File

@@ -47,6 +47,27 @@ static akerr_ErrorContext *state_of(akbasic_GraphicsBackend *self, akbasic_AkglG
SUCCEED_RETURN(errctx);
}
/**
* @brief How big the renderer's output is, which is the space BASIC draws into.
*
* SDL_GetCurrentRenderOutputSize rather than the window size: the two differ on
* a high-DPI display and on a renderer with a logical presentation set, and it
* is the output that a coordinate handed to akgl_draw_point actually indexes.
*/
static akerr_ErrorContext *gfx_size(akbasic_GraphicsBackend *self, int *width, int *height)
{
PREPARE_ERROR(errctx);
akbasic_AkglGraphics *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (width != NULL && height != NULL), AKERR_NULLPOINTER,
"NULL destination in gfx_size");
FAIL_ZERO_RETURN(errctx,
SDL_GetCurrentRenderOutputSize(state->renderer->sdl_renderer, width, height),
AKGL_ERR_SDL, "%s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *gfx_point(akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color)
{
PREPARE_ERROR(errctx);
@@ -134,9 +155,7 @@ static akerr_ErrorContext *gfx_clear(akbasic_GraphicsBackend *self, akbasic_Colo
int h = 0;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx,
SDL_GetCurrentRenderOutputSize(state->renderer->sdl_renderer, &w, &h),
AKGL_ERR_SDL, "%s", SDL_GetError());
PASS(errctx, gfx_size(self, &w, &h));
/*
* A filled rectangle over the output rather than SDL_RenderClear, because
@@ -231,6 +250,7 @@ akerr_ErrorContext *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akb
state->renderer = renderer;
obj->self = state;
obj->size = gfx_size;
obj->point = gfx_point;
obj->line = gfx_line;
obj->rect = gfx_rect;

View File

@@ -85,6 +85,14 @@ akerr_ErrorContext *akbasic_graphics_state_init(akbasic_GraphicsState *obj)
obj->linewidth = 1;
obj->xmax = (double)AKBASIC_GRAPHICS_WIDTH;
obj->ymax = (double)AKBASIC_GRAPHICS_HEIGHT;
/*
* The size assumed until a device says otherwise, which require_graphics()
* asks it before every verb that draws. GRAPHIC CLR comes back through here
* and deliberately resets it: the next verb re-asks, so a stale size cannot
* outlive the device that reported it.
*/
obj->devwidth = AKBASIC_GRAPHICS_WIDTH;
obj->devheight = AKBASIC_GRAPHICS_HEIGHT;
for ( source = 0; source < AKBASIC_COLOR_SOURCES; source++ ) {
obj->source[source] = SOURCE_DEFAULTS[source];
}

View File

@@ -39,40 +39,73 @@
#define CIRCLE_DEFAULT_INC 2.0
/**
* @brief Refuse politely when the host lent us no graphics device.
* @brief Refuse politely when the host lent us no graphics device, and ask its size.
*
* 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.
*
* It is also where the drawing surface's size is refreshed, which is why the two
* jobs are one function: the size is wanted by every verb that draws, every verb
* that draws already opens with this, and a window can be resized between two
* statements. A backend with no `size` -- it is the record's one optional entry
* point -- keeps whatever akbasic_graphics_state_init() left, which is 320x200.
*/
static akerr_ErrorContext *require_graphics(akbasic_Runtime *obj, const char *verb)
{
PREPARE_ERROR(errctx);
int width = 0;
int height = 0;
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);
if ( obj->graphics->size != NULL ) {
PASS(errctx, obj->graphics->size(obj->graphics, &width, &height));
/*
* A device reporting nothing useful is not an error -- an SDL window
* mid-resize legitimately measures zero -- but it must not become the
* space a program draws into, because dividing by it in scale_point
* would send every scaled coordinate to infinity.
*/
if ( width > 0 && height > 0 ) {
obj->gfx.devwidth = width;
obj->gfx.devheight = height;
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Map a user coordinate onto the 320x200 space the backend receives.
* @brief Map a user coordinate onto the device pixels 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.
* With SCALE off this is the identity, and a coordinate is a device pixel: the
* whole of the host's window is reachable from BASIC. With SCALE on, the
* program's coordinates run 0..xmax across the same surface, so the ratio is all
* there is to it -- and the surface is however big the device last said it was,
* which is what makes `SCALE 1, 319, 199` the way to run a C128 listing full
* screen.
*
* **`xmax` maps onto the last pixel, not onto the width**, which is a
* one-character difference and the whole difference between that sentence being
* true and being nearly true. Dividing by the width sends `SCALE 1, 319, 199`
* plus `DRAW 1, 319, 199` to (width, height) -- one past the bottom-right
* corner, off the surface, drawing nothing. A user maximum is a coordinate the
* program is entitled to plot, the same way 7.0's own `SCALE 1` then
* `DRAW 1, 1023, 1023` reaches the corner of a C128 screen rather than missing 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->xmax > 0.0 && gfx->devwidth > 1 ) {
*x = (*x / gfx->xmax) * (double)(gfx->devwidth - 1);
}
if ( gfx->ymax > 0.0 ) {
*y = (*y / gfx->ymax) * (double)AKBASIC_GRAPHICS_HEIGHT;
if ( gfx->ymax > 0.0 && gfx->devheight > 1 ) {
*y = (*y / gfx->ymax) * (double)(gfx->devheight - 1);
}
}
@@ -249,6 +282,61 @@ akerr_ErrorContext *akbasic_cmd_scale(akbasic_Runtime *obj, akbasic_ASTLeaf *exp
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------------- RGR --- */
akerr_ErrorContext *akbasic_fn_rgr(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_Value *out = NULL;
double args[1];
int count = 0;
int field = 0;
int64_t answer = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in RGR");
PASS(errctx, akbasic_args_numbers(obj, expr, "RGR", args, 1, &count));
FAIL_ZERO_RETURN(errctx, (count >= 1), AKBASIC_ERR_SYNTAX, "RGR expected a field number");
field = (int)args[0];
/*
* Field 0 is BASIC 7.0's whole RGR: the current GRAPHIC mode. It answers
* from state and works with no device, because a program can ask what mode
* it set without a screen to set it on.
*
* Fields 1 and 2 are ours, and they are the half of item 9 a program can
* see: the drawing surface's real width and height in pixels, so a listing
* can fill whatever window the host opened instead of assuming 320x200.
* They go through require_graphics(), which is both the refusal when there
* is no device -- asking how big the screen is when there is no screen is a
* program bug worth reporting -- and the refresh that makes the answer
* current rather than whatever it was at attach.
*/
switch ( field ) {
case 0:
answer = (int64_t)obj->gfx.mode;
break;
case 1:
PASS(errctx, require_graphics(obj, "RGR"));
answer = (int64_t)obj->gfx.devwidth;
break;
case 2:
PASS(errctx, require_graphics(obj, "RGR"));
answer = (int64_t)obj->gfx.devheight;
break;
default:
FAIL_RETURN(errctx, AKBASIC_ERR_BOUNDS, "RGR: field %d is outside 0..2", field);
}
PASS(errctx, akbasic_environment_new_value(obj->environment, &out));
PASS(errctx, akbasic_value_zero(out));
out->valuetype = AKBASIC_TYPE_INTEGER;
out->intval = answer;
*dest = out;
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- DRAW --- */
akerr_ErrorContext *akbasic_cmd_draw(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)

View File

@@ -136,6 +136,7 @@ static const akbasic_Verb VERBS[] = {
{ "RESTORE", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_restore },
{ "RESUME", AKBASIC_TOK_COMMAND, -1, akbasic_parse_resume, akbasic_cmd_resume },
{ "RETURN", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_return },
{ "RGR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_rgr },
{ "RIGHT", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_right },
{ "RSPCOLOR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_rspcolor },
{ "RSPPOS", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_rsppos },

View File

@@ -171,6 +171,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_locate(struct akbasic_Runtime *ob
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);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rgr(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
/* Group I sound verbs -- src/runtime_audio.c */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_envelope(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);

View File

@@ -148,6 +148,43 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_draw_reaches_pixels(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief The renderer's own size is the space BASIC draws into.
*
* The one test that puts a real SDL renderer behind item 9. The target here is
* 128x128 -- deliberately *smaller* than the 320x200 fallback -- so a `SCALE`
* that still divided by the old constants would put its far corner at 2.5 times
* the target's width and light nothing at all.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_size_is_the_renderer(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 PRINT RGR(1)\n20 PRINT RGR(2)\n"));
TEST_REQUIRE_STR(OUTPUT, "128\n128\n");
stop_runtime();
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 SCALE 1, 1000, 1000\n"
"20 DRAW 1, 1000, 1000\n"));
TEST_REQUIRE_STR(OUTPUT, "");
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
/*
* The far corner of the user space lands on the far corner of the target.
* The old code divided by the 320x200 constants, which would have put this
* at (400, 400) -- off a 128x128 surface entirely, lighting nothing.
*/
TEST_REQUIRE(pixel_is(shot, TARGET_SIZE - 1, TARGET_SIZE - 1, 0xff, 0xff, 0xff),
"SCALE 1,1000,1000 then DRAW 1,1000,1000 should reach the bottom-right pixel");
SDL_DestroySurface(shot);
stop_runtime();
SUCCEED_RETURN(errctx);
}
/** @brief A BOX outlines: its corners are lit and its middle is not. */
static akerr_ErrorContext AKERR_NOIGNORE *test_box_outlines(void)
{
@@ -519,6 +556,7 @@ int main(void)
"Couldn't open %s: %s", AKBASIC_TEST_FONT, SDL_GetError());
CATCH(errctx, test_draw_reaches_pixels());
CATCH(errctx, test_size_is_the_renderer());
CATCH(errctx, test_box_outlines());
CATCH(errctx, test_paint_fills_region());
CATCH(errctx, test_shape_roundtrip());

View File

@@ -172,22 +172,39 @@ static void test_paint(void)
harness_stop();
}
/** @brief SCALE maps user coordinates onto the 320x200 space, and only then. */
/**
* @brief SCALE maps user coordinates onto the device's own size, and only then.
*
* The mock reports 640x400, which is what every scaled expectation below is
* against. If the interpreter ever stops asking the device and goes back to the
* 320x200 constants, every one of them is out by a factor of two -- which is
* the reason the mock's size is not 320x200.
*/
static void test_scale(void)
{
/* Off by default: a coordinate passes through untouched. */
/* Off by default: a coordinate is a device pixel and passes through
untouched, which is what puts the whole of the host's window in reach. */
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");
/* A coordinate past the old 320x200 ceiling is not clamped, refused, or
wrapped. This is item 9 in one assertion. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, 639, 399\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.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");
/* On, with the 7.0 default maxima: the far corner of the user space is the
far corner of the *device*, not of a 320x200 screen inside it -- and it
is the last pixel, 639, rather than one past it. */
TEST_REQUIRE_OK(run_program("10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
/* And a C128 listing fills the window by declaring the space it was
written for, which is the migration path for every one of them. */
TEST_REQUIRE_OK(run_program("10 SCALE 1, 319, 199\n20 DRAW 1, 319, 199\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 SCALE 1, 0, 400\n"));
@@ -196,6 +213,84 @@ static void test_scale(void)
harness_stop();
}
/**
* @brief A backend that will not say how big it is gets the 320x200 fallback.
*
* `size` is the record's one optional entry point, so a host that filled a
* backend in before it existed has to keep working -- and what it gets is the
* space a C128 listing was written for rather than a division by zero.
*/
static void test_scale_without_a_size(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK_GRAPHICS.size = NULL;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(MOCK.log, "point 319.0,199.0 " WHITE "\n");
harness_stop();
/*
* A device reporting nothing useful -- an SDL window mid-resize measures
* zero -- is not an error and must not become the space, because dividing
* by it would send every scaled coordinate to infinity.
*/
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK.devwidth = 0;
MOCK.devheight = 0;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(MOCK.log, "point 319.0,199.0 " WHITE "\n");
harness_stop();
}
/** @brief RGR answers the mode, the width and the height, and refuses the rest. */
static void test_rgr(void)
{
TEST_REQUIRE_OK(run_program("10 GRAPHIC 3\n20 PRINT RGR(0)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RGR(1)\n20 PRINT RGR(2)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "640\n400\n");
harness_stop();
/* The whole point of asking: a program can draw to the far corner without
knowing in advance what size window the host opened. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, RGR(1) - 1, RGR(2) - 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RGR(3)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside 0..2") != NULL,
"RGR(3) should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/*
* The mode is state and answers with no device; a size is not, and asking
* for one when there is no screen is a program bug worth reporting.
*/
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RGR(0)\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, "0\n");
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RGR(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, "RGR needs a graphics device") != NULL,
"RGR(1) with no device should refuse, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief GRAPHIC records the mode, clears on request and refuses a bad mode. */
static void test_graphic(void)
{
@@ -280,6 +375,8 @@ int main(void)
test_circle();
test_paint();
test_scale();
test_scale_without_a_size();
test_rgr();
test_graphic();
test_shapes();
test_no_device();

View File

@@ -43,6 +43,8 @@ typedef struct
/* Graphics */
int shapes; /* handles handed out so far */
bool paint_exhausts; /* when true, paint reports a partial fill */
int devwidth; /* what size() reports; see mock_devices_init */
int devheight;
/* Audio */
bool voice_active[AKBASIC_AUDIO_VOICES];
@@ -94,6 +96,24 @@ static void mock_log(const char *format, ...)
/* --------------------------------------------------------------- graphics -- */
/**
* @brief Report the recorded surface size, and log that it was asked.
*
* Logged like every other call because *when* the interpreter asks matters: it
* re-asks before each verb that draws, deliberately, so a resized window is not
* drawn to at its old size.
*/
static akerr_ErrorContext *mock_size(akbasic_GraphicsBackend *self, int *width, int *height)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_ZERO_RETURN(errctx, (width != NULL && height != NULL), AKERR_NULLPOINTER,
"NULL destination in mock_size");
*width = MOCK.devwidth;
*height = MOCK.devheight;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_point(akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color)
{
PREPARE_ERROR(errctx);
@@ -415,8 +435,18 @@ __attribute__((unused))
static void mock_devices_init(void)
{
memset(&MOCK, 0, sizeof(MOCK));
/*
* 640x400 rather than 320x200, on purpose: it is exactly twice the fallback,
* so a scaled coordinate that came out right by accident -- because nothing
* consulted the device at all -- is off by a factor of two rather than
* indistinguishable. A test that wants the fallback clears MOCK_GRAPHICS.size,
* the way the SOUND test clears MOCK_AUDIO.sweep.
*/
MOCK.devwidth = 640;
MOCK.devheight = 400;
MOCK_GRAPHICS.self = &MOCK;
MOCK_GRAPHICS.size = mock_size;
MOCK_GRAPHICS.point = mock_point;
MOCK_GRAPHICS.line = mock_line;
MOCK_GRAPHICS.rect = mock_rect;