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

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;