joeylib2/docs/ProgramLayout.adoc
2026-09-26 21:02:41 -05:00

359 lines
10 KiB
Text

== Anatomy of a JoeyLib Program
Every JoeyLib program has the same four-part shape: configure and initialise,
set up the screen and load assets, run a frame loop, shut down. This chapter
walks through each part and ends with a complete annotated skeleton you can
copy.
=== Part 1: Configure and Initialise
[source,c]
----
jlConfigT config;
config.codegenBytes = 32UL * 1024; // sprite blitter cache
config.audioBytes = 0; // reserved, not yet consulted
if (!jlInit(&config)) {
// jlLastError() explains why. There is no display yet, so on a
// real machine the only place to report this is stderr or a log.
return 1;
}
----
`codegenBytes` sizes the *codegen arena*: the scratch memory the library emits
native sprite blitter code into. Getting it wrong is the most common cause of
sprites drawing slowly, so it is worth understanding.
* `0` selects a per-platform default: 8 KB on the IIgs, 32 KB on
Amiga, ST and DOS.
* The 68000 and x86 emitters produce code roughly four to eight times less
dense than the 65816 one, so the same sprite needs far more arena space on
those ports. A 32x32 sprite can need 8 to 16 KB there.
* Measure, do not guess: after compiling a sprite, `jlSpriteCompiledSize()`
reports its exact cost on the current platform.
* On the IIgs the arena is one Memory Manager block that may not cross a 64 KB
bank boundary, so requests above 65536 are clamped to 65536 -- and even a
clamped request can fail if no free bank exists.
If the arena is too small, sprites still draw: they fall back to an
interpreter that is roughly *forty times slower*. Nothing reports this as an
error, so a program that never checks can be silently crawling.
=== Part 2: The Stage, the Palette and Assets
[source,c]
----
jlSurfaceT *stage = jlStageGet();
----
The *stage* is the library-owned back buffer. You never create or destroy it;
it is valid from `jlInit` to `jlShutdown`. All drawing goes here, and
`jlStagePresent()` copies it to the real display.
Set up colour before drawing:
[source,c]
----
uint16_t colors[SURFACE_COLORS_PER_PALETTE];
// ... fill in 16 entries of $0RGB ...
jlPaletteSet(stage, 0, colors);
jlScbSetRange(stage, 0, SURFACE_HEIGHT - 1, 0);
----
Each surface carries *16 palettes of 16 colours* plus a 200-entry table (one
per scanline) selecting which palette that line uses. That table is the *SCB*,
or scanline control byte table. Using one palette everywhere is the simple
case; assigning different palettes to different bands is how a JoeyLib program
shows more than 16 colours at once.
Load assets after init, because sprite loading needs the codegen arena:
[source,c]
----
jlSpriteT *cels[16];
uint16_t palette[SURFACE_COLORS_PER_PALETTE];
uint16_t count = jlSpriteBankLoad("player.spr", cels, 16, palette);
uint16_t i;
for (i = 0; i < count; i++) {
jlSpritePrewarm(cels[i]); // compile NOW, not mid-frame
}
----
[IMPORTANT]
====
`jlSpriteDraw` does *not* compile a sprite on demand. An uncompiled sprite
draws through the interpreter every single time. Call `jlSpriteCompile` or
`jlSpritePrewarm` during loading -- otherwise the first draw of each sprite
either stalls a frame (if you compile lazily yourself) or never gets fast at
all.
====
=== Part 3: The Frame Loop
The library does not schedule anything. You own the loop. The canonical shape:
[source,c]
----
while (running) {
jlWaitVBL(); // pace to the display's refresh
jlInputPoll(); // refresh keyboard, mouse, joystick
jlAudioFrameTick(); // pump the audio engine
if (jlKeyPressed(KEY_ESCAPE)) {
running = false;
}
updateGame(); // your logic
renderFrame(stage); // your drawing
jlStagePresent(); // make it visible
}
----
Four library calls, in that order, and everything else is yours.
`jlWaitVBL()` blocks until the next vertical blank. It is cheap on every port
because the underlying mechanism is always a hardware wait, never a software
timer.
`jlInputPoll()` must be called exactly once per frame. The edge predicates
(`jlKeyPressed`, `jlKeyReleased`, and the mouse and joystick equivalents) are
defined relative to the *previous poll*, so calling it twice per frame makes
edges disappear, and not calling it means nothing ever changes.
`jlAudioFrameTick()` is a no-op on ports whose audio runs from a hardware
interrupt, but it is required on ports that mix in user context, and it is what
advances tracker music and keeps streaming sound effects fed. Call it
unconditionally, once per frame.
`jlStagePresent()` flips the dirty parts of the stage to the display. The
drawing primitives record which rows they touched, so presenting only copies
what actually changed. You do not need to tell it what you drew.
[NOTE]
====
If you write pixels by some route other than the library's primitives, the
dirty tracking does not know about it, and `jlStagePresent` will skip those
rows. That is the one case where you have to mark the region yourself.
====
==== Frame-Rate Independence
The five targets do not share a refresh rate: 50 Hz (Amiga PAL), 60 Hz (IIgs,
ST NTSC, X68000) and 70 Hz (VGA mode 13h). A loop that moves a sprite "two
pixels per frame" runs 40 percent faster on DOS than on a PAL Amiga.
Two ways to handle it. Ask the frame rate and scale your per-frame step:
[source,c]
----
uint16_t hz = jlFrameHz(); // 50, 60 or 70
----
Or drive from the millisecond clock, which is the more robust choice for
anything that must feel the same everywhere:
[source,c]
----
uint32_t now = jlMillisElapsed();
uint32_t step = now - previous;
previous = now;
----
`jlMillisElapsed()` reports true elapsed wall-clock time, so a frame that took
a long time to render is accounted for correctly rather than counted as one
tick.
=== Part 4: Shut Down
[source,c]
----
jlAudioShutdown(); // if you called jlAudioInit
jlShutdown();
return 0;
----
`jlShutdown` restores the display mode, frees the library's allocations, and
un-hooks interrupt vectors. Sprites and surfaces you created are released with
it, but the buffers *you* allocated (including tile data you handed to
`jlSpriteCreate`, which is never copied) remain yours to free.
=== The Complete Skeleton
This compiles and runs on all five targets as-is.
[source,c]
----
// A minimal but complete JoeyLib program.
#include <joey/joey.h>
#define PLAYER_SPEED 2
static jlSurfaceT *gStage;
static bool gRunning = true;
static int16_t gPlayerX = 160;
static int16_t gPlayerY = 100;
static void buildPalette(void);
static void handleInput(void);
static void renderFrame(void);
static void buildPalette(void) {
uint16_t colors[SURFACE_COLORS_PER_PALETTE];
uint8_t i;
for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) {
colors[i] = 0x000;
}
colors[1] = 0xFFF; // white
colors[2] = 0x0C0; // green
colors[3] = 0x00C; // blue
jlPaletteSet(gStage, 0, colors);
jlScbSetRange(gStage, 0, SURFACE_HEIGHT - 1, 0);
}
static void handleInput(void) {
if (jlKeyPressed(KEY_ESCAPE)) {
gRunning = false;
}
if (jlKeyDown(KEY_LEFT)) {
gPlayerX -= PLAYER_SPEED;
}
if (jlKeyDown(KEY_RIGHT)) {
gPlayerX += PLAYER_SPEED;
}
if (jlKeyDown(KEY_UP)) {
gPlayerY -= PLAYER_SPEED;
}
if (jlKeyDown(KEY_DOWN)) {
gPlayerY += PLAYER_SPEED;
}
// A joystick, if one is attached, drives the same movement.
if (jlJoystickConnected(JOYSTICK_0)) {
if (jlJoystickX(JOYSTICK_0) < -64) {
gPlayerX -= PLAYER_SPEED;
}
if (jlJoystickX(JOYSTICK_0) > 64) {
gPlayerX += PLAYER_SPEED;
}
}
// Keep the player on screen: the primitives would clip safely, but
// clamping keeps the object whole.
if (gPlayerX < 0) {
gPlayerX = 0;
}
if (gPlayerX > SURFACE_WIDTH - 16) {
gPlayerX = SURFACE_WIDTH - 16;
}
if (gPlayerY < 0) {
gPlayerY = 0;
}
if (gPlayerY > SURFACE_HEIGHT - 16) {
gPlayerY = SURFACE_HEIGHT - 16;
}
}
static void renderFrame(void) {
jlSurfaceClear(gStage, 0);
jlDrawRect(gStage, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, 1);
jlFillRect(gStage, gPlayerX, gPlayerY, 16, 16, 2);
}
int main(void) {
jlConfigT config;
config.codegenBytes = 32UL * 1024;
config.audioBytes = 0;
if (!jlInit(&config)) {
return 1;
}
gStage = jlStageGet();
if (gStage == NULL) {
jlShutdown();
return 1;
}
buildPalette();
jlRandomSeed(12345); // deterministic on every port
while (gRunning) {
jlWaitVBL();
jlInputPoll();
jlAudioFrameTick();
handleInput();
renderFrame();
jlStagePresent();
}
jlShutdown();
return 0;
}
----
=== Two Drawing Strategies
Once you have moving objects over a background, you need to erase them. There
are two patterns, and *mixing them in the same region corrupts the screen*.
==== Save-Under and Restore
Before drawing a sprite, capture what is underneath; next frame, put it back.
[source,c]
----
uint16_t backing[JOEY_SPRITE_BACKUP_BYTES(2, 2) / 2];
jlSpriteBackupT backup;
backup.bytes = (uint8_t *)backing;
backup.sizeBytes = sizeof(backing);
// each frame:
jlSpriteRestoreUnder(stage, &backup); // erase last frame's sprite
jlSpriteSaveAndDraw(stage, sprite, x, y, &backup);
----
Size the buffer with the `JOEY_SPRITE_BACKUP_BYTES` macro, and declare it as
`uint16_t` (or otherwise 2-byte aligned) -- an odd address pushes save and
restore onto slow interpreted paths on the planar ports, because the 68000
cannot do word accesses at odd addresses.
==== The Clean-Buffer Pattern
Keep a pristine copy of the background, and restore rectangles from it.
[source,c]
----
jlSurfaceT *clean = jlSurfaceCreate();
// ... draw the static scene into clean, once ...
// each frame:
jlSurfaceCopyRect(stage, clean, x, y, w, h); // erase EVERY window first
// ... then draw ALL sprites ...
----
This scales better with many sprites, and it is the right choice when sprites
overlap. Erase every window first, then draw every sprite -- never interleave
erase and draw.
[WARNING]
====
`jlSurfaceCopyRect` widens the copied region outward to the 16-pixel group grid
on *every* port, so it can touch up to 15 pixels either side of the rectangle
you asked for. That means it can overwrite content just outside your rectangle,
and it silently invalidates any live `jlSpriteBackupT` whose recorded window it
touches. This is exactly why the two patterns must not be mixed in one region.
====