joeylib2/docs/Portability.adoc
2026-10-05 16:47:00 -05:00

386 lines
15 KiB
Text

== Writing Portable Code
A JoeyLib program that follows the rules in this chapter compiles and behaves
the same on all five machines and on DVX. Most of the rules exist because one
particular target cannot do something the others can, and the Apple IIgs is
usually that target.
=== The Rules, in Short
. Use `jlAlloc` for anything large, never `malloc`.
. Never assume a frame rate. Ask `jlFrameHz()` or use `jlMillisElapsed()`.
. Never assume colour 0 is anything but black.
. Compile your sprites before you draw them.
. Keep large objects out of function-local variables on the IIgs.
. Use `jlRandom`, not `rand`, when identical behaviour across ports matters.
. Keep file names short, uppercase-safe and 8.3-compatible.
. Do not mix save-under with clean-buffer erasing in the same screen region.
. Pick one of the audio layers per scene, not two.
The rest of this chapter explains each one.
=== Memory
This is the rule that catches everyone. *The C heap on the IIgs is tiny.* It
lives in bank 0, and a single `malloc` is capped at roughly 32 KB -- often far
less in practice, because the clang libc heap on that port can be a few hundred
bytes. A program that `malloc`s a 32000-byte buffer works on four targets and
fails on the reference one.
Use the library allocator, which is backed by each platform's native allocator
(the Memory Manager on the IIgs):
[source,c]
----
uint8_t *buffer = (uint8_t *)jlAlloc(32000);
if (buffer == NULL) {
// handle it -- this genuinely can fail on a stock machine
}
// ...
jlFree(buffer);
----
`jlAlloc` does *not* zero the memory it returns.
For bulk copies, `jlMemCopy` and `memcpy` are the same thing: `jlMemCopy` is
`memcpy` with a 32-bit count. On the IIgs both are `MVN` block moves at 7
cycles per byte, split correctly at bank boundaries.
==== Large Local Variables
On the IIgs, a large function-local variable in a deep call chain can silently
overflow the soft stack and crash -- roughly 2 KB is enough to do it, and the
trigger is call depth rather than anything the compiler warns about. Make big
buffers `static`, or allocate them with `jlAlloc`:
[source,c]
----
// DANGEROUS on the IIgs, especially deep in a call chain:
void renderScene(void) {
uint8_t scratch[2048];
// ...
}
// Safe everywhere:
void renderScene(void) {
static uint8_t scratch[2048];
// ...
}
----
=== Timing
Covered in <<_frame_rate_independence>>, and worth repeating because it is the
second most common portability bug: the targets run at 50, 60 and 70 Hz. Scale
by `jlFrameHz()` or work from `jlMillisElapsed()`.
`jlFrameCount()` is a 16-bit counter, so you must poll it faster than twice the
frame rate or you will miss edges, and it wraps roughly every 18 minutes at
60 Hz.
=== Colour and the Palette
Colour 0 is black in every palette on every platform, and `jlPaletteSet`
silently rewrites entry 0 to `$000` no matter what you pass. Do not design
artwork that needs colour 0 to be something else -- you have 15 free colours per
palette, and 16 palettes to switch between per scanline.
Colour 0 is also the transparent colour for sprites. A sprite pixel of colour 0
is never drawn. If you need an opaque rectangle, use the tile operations, which
have both opaque and masked forms.
=== Sprites
Two rules.
*Compile before drawing.* `jlSpriteDraw` never compiles on demand. An
uncompiled sprite runs through the interpreter at roughly 1/40th the speed, and
nothing warns you.
*Align your backup buffers.* Give any `jlSpriteBackupT` backing array 2-byte
alignment, by declaring it `uint16_t` or with an alignment attribute. An odd
buffer address routes save and restore onto slow interpreted paths on the
planar ports, because the 68000 cannot perform word accesses at odd addresses.
[source,c]
----
// Right: 2-byte aligned by construction.
static uint16_t gBacking[JOEY_SPRITE_BACKUP_BYTES(4, 4) / 2];
// Risky: may land on an odd address.
static uint8_t gBacking[JOEY_SPRITE_BACKUP_BYTES(4, 4)];
----
Note also that on the planar ports the region a save-under actually captures is
the *16-pixel-aligned window* containing the sprite, not the sprite rectangle.
`jlSpriteSaveUnder` reports the true geometry in the backup structure's
`x`, `width`, `height` and `sizeBytes` fields. It is that recorded window you
must not overwrite before restoring.
=== Randomness
`rand()` differs wildly between C libraries, so a procedurally generated level
would not match across ports. `jlRandom` is xorshift32 and produces
*bit-identical* output on every platform for a given seed:
[source,c]
----
jlRandomSeed(12345);
uint16_t roll = jlRandomRange(6); // 0..5, same everywhere
----
A seed of 0 is remapped to 1, because xorshift cannot escape the all-zero
state. This generator is fine for games and useless for cryptography.
=== Files
Two directories sit beside the executable, and the library prefixes them for
you:
* `DATA/` -- read-only game content, opened with `jlDataOpen`
* `SAVES/` -- writable saves and preferences, via the `jlSave*` calls
Pass bare names: `jlDataOpen("levels/title.dat", "rb")`, never
`"DATA/levels/title.dat"`. On DVX the two are not in the current directory
at all (<<_dvx_programs>>), so a program that opens `"DATA/..."` itself finds
nothing there.
Keep names portable across the retro filesystems this library targets: *at most
8 characters plus a short extension, letters and digits only*, so they satisfy
both ProDOS and DOS 8.3. Do not rely on case being preserved.
A failed save is not fatal -- the calls report failure and your program should
degrade gracefully, because on some volumes `SAVES/` simply is not writable.
=== Audio
JoeyLib gives you three sound layers, and on two platforms they share physical
hardware:
[cols="2,5", options="header"]
|===
|Layer |What it is
|Module playback
|`jlAudioPlayMod` -- four-channel Protracker `.MOD`, mixed in software on
DOS and ST, hardware-replayed on Amiga and IIgs.
|Chip tracker
|`jlMusicPlay` -- a portable event stream driving three tone voices plus
noise, at register-write cost with no CPU mixing.
|Voices and effects
|`jlAudioVoice`, `jlAudioNoise`, `jlAudioPlaySfx` and the streaming forms.
|===
[WARNING]
====
On the Atari ST and Amiga, the tone voices *are* the same hardware the module
player drives. While a `.MOD` is playing, `jlAudioVoice` writes fight the
player's register writes and lose. Choose the module layer or the voice and
tracker layer *per scene*, not both at once.
====
The chip tracker is the portable choice for music. It costs almost nothing on
every port -- unlike `.MOD` playback, which needs real CPU mixing and is
marginal on the ST.
On the IIgs, `jlAudioVoice` and `jlAudioNoise` require `jlAudioInit` (they ride
the tracker engine's DOC path) and behave as retriggered one-shots of about
80 ms, so a note held longer must be re-asserted roughly once a frame. On DOS,
ST and Amiga those two calls are direct hardware programming and work without
`jlAudioInit`.
A failed `jlAudioInit` is *not* fatal. `jlInit` still succeeds and every audio
call becomes a silent no-op, so a program need not treat missing sound as an
error.
=== Per-Platform Behaviour Summary
The API is uniform; these are the places where what you get differs.
[cols="2,2,2,2,2,2,2", options="header"]
|===
|Feature |IIgs |Amiga |Atari ST |MS-DOS |X68000 |DVX
|Frame rate
|60 Hz
|50 Hz (PAL)
|60 Hz (NTSC)
|70 Hz
|55 Hz (56 under MAME)
|70 Hz (virtual; the real retrace on the whole screen)
|Pixel storage
|chunky 4bpp
|planar
|planar, interleaved
|chunky 4bpp
|text-plane planar
|chunky 4bpp
|Stereo pan
|all three
|left and right
|ignored (mono)
|ignored (mono)
|ignored
|all three
|`jlAudioTone`
|DOC oscillator
|Paula
|YM2149
|PIT counter 2
|-
|a DVX tone (FM chip or mixer)
|Joystick
|analog, auto-disables
|digital
|digital
|digital
|digital
|analog (DVX calibrates)
|Keyboard layout
|machine's own
|machine's own
|machine's own
|fixed US
|machine's own
|DVX's
|Serial ports
|modem, printer, slot card
|`serial.device`
|Modem port
|COM1..COM4
|-
|COM1..COM4 (DVX's library)
|===
The joystick row deserves a note: the IIgs port *auto-disables* joystick
polling after a short window with no stick detected, because polling costs
about 3 ms per frame there. It does not re-probe on its own. If a user plugs a
stick in while the program is running, you must call `jlJoystickReset` to
resume polling.
On the X68000 both joystick ports always report a stick, as on the ST: an
empty port cannot be told from a stick at rest. `JOY_BUTTON_0` is a stick's
trigger A and `JOY_BUTTON_1` its trigger B. Holding both directions of an axis
reads as the positive one (right, down), as on the Amiga and ST.
The X68000's display refreshes at 55.46 Hz. `jlFrameHz` times it when the
program starts and rounds to the nearest whole number, so it reports 55 --
and 56 under MAME, whose X68000 display runs slightly fast (56.07 Hz).
`jlMillisElapsed` does not depend on it: it reads the machine's 100 Hz system
timer to the millisecond.
Per-scanline palettes cost something on the X68000, and only there. Its
display has one 16-colour palette, so the port reloads it as the beam scans: an
interrupt stores the next band's colours in the horizontal blanking before the
band's first line. A screen with one palette costs nothing and arms no
interrupt. Each change of palette between one line and the next costs about
one line of CPU time (32 microseconds); a band more than two lines from the
one before costs about two. A screen that changes palette on every line
therefore spends a third of each frame in that interrupt, during which serial
and mouse interrupts wait (see the serial chapter for how long a run can be
before received bytes are lost). Palette and SCB changes take effect at the
start of the next frame rather than mid-frame, so a fade can lag its pixels by
one frame but never tears.
=== A Portability Checklist
Before you call a program portable, confirm:
* [ ] No `malloc` of anything over a few hundred bytes -- use `jlAlloc`.
* [ ] No large function-local arrays; they are `static` or heap.
* [ ] Movement and animation scale with `jlFrameHz()` or `jlMillisElapsed()`.
* [ ] `jlInputPoll()` is called exactly once per frame.
* [ ] `jlAudioFrameTick()` is called every frame.
* [ ] Every sprite is compiled during loading.
* [ ] Sprite backup buffers are 2-byte aligned and sized with the macro.
* [ ] Save file names are 8.3-safe.
* [ ] Only one audio layer is active per scene.
* [ ] `jlShutdown()` runs on every exit path.
* [ ] The program has actually been run on every target, not just built.
That last item is the one that matters. Every port has emulator run scripts
under `scripts/`, and building successfully proves considerably less than
running.
=== DVX Programs
DVX is a DOS desktop: a VESA graphics GUI with windows, cooperative
multitasking and its own libraries for sound, music, joysticks and serial
ports. On DVX a JoeyLib program is one of its programs, a `.app` module
running in a window of its own beside the others. The graphics are the
MS-DOS port's -- the same chunky stage and the same x86 code, so UBER's
hashes are DOS's -- and the rest behaves like this:
* *A window, or the whole screen.* The window's content is the 320x200 stage
at the largest whole scale, up to 3x, that fits the desktop: 1x at
640x480, 2x at 800x600, 3x at 1024x768. The stage reaches the desktop
through one colour table per palette, so SCB palettes are exact on 15-,
16- and 32-bit desktops. On a 256-colour desktop every colour is the
nearest one in DVX's palette, which all programs share: gradients band.
* *Full screen.* Game > Full Screen, or Alt+Enter, puts the program on the
whole screen in DVX's full-screen mode, 320x200 in 256 colours (VGA mode
13h, or a VBE mode of that size), and back in its window. There it looks
exactly as on MS-DOS: the screen's 256 colours hold all 16 palettes, so
SCB palettes are exact at any desktop depth and palette changes are free.
The other programs run on meanwhile. Alt+Tab, Ctrl+Esc or another window
coming to the front brings the desktop back, with the program still
running and drawing in its window; giving its window the focus again puts
it back on the whole screen. The choice is kept in the program's settings
folder (`JOEYLIB.INI`), so it starts the way it was left. It is the user's
choice: JoeyLib's API has no full-screen call. (Under DOSBox, Alt+Enter is
DOSBox's own full-screen key; use Alt with the keypad's Enter, or the
menu.)
* *A virtual 70 Hz.* A window has no vertical blank. `jlFrameHz()` is 70, as
on DOS, and `jlWaitVBL()` waits for the next seventieth of a second of
DVX's clock while the other programs run; nothing prevents tearing. On the
whole screen `jlWaitVBL()` gives the other programs a turn, then waits for
the screen's real vertical retrace, as on DOS, so a present right after it
does not tear. The frame count is the same clock in both, so switching
never makes it jump.
* *Keys DVX keeps.* While the window is active, or the program has the whole
screen, every key goes to the program, F1, F10 and Alt included, except
Alt+Tab (switch programs), Ctrl+Esc (Task Manager) and Alt+F4 (close the
window), and Alt+Enter, which the port keeps for full screen. The window's
Game menu has Full Screen, Help (the program's help file) and Exit. Typed
characters come through DVX's keyboard layout.
* *Closing ends the program.* Alt+F4, the close box and Game > Exit end the
program at its next `jlWaitVBL`, `jlInputPoll`, `jlStagePresent` or
`jlSerialPoll`, as `exit()` does anywhere: its `atexit` handlers run, then
`jlShutdown`, and its log is closed.
* *Sharing the machine.* DVX multitasks cooperatively: the other programs,
and this one's own window events, run only while it yields. `jlWaitVBL`
yields, and so do `jlInputPoll`, `jlStagePresent` and `jlSerialPoll`, at
most once a frame. A loop that calls none of them freezes the desktop until
it ends.
* *Files.* DVX's programs share one current directory, so JoeyLib roots its
files at run time: `DATA/` is found beside the program (its folder under
`APPS`), while `SAVES/` and `joeylog.txt` go to the program's own settings
folder, `CONFIG\APPS\JOEYLIB\NAME`, which DVX keeps apart from the program
files so a reinstall never overwrites them.
* *Sound* goes through DVX's sound and music libraries, mixed with every
other program's: `.MOD` music, SFX in stereo, and the tone voices on the FM
chip when there is one. A `jlAudioTickRegister` callback runs in program
time, at the next `jlAudioFrameTick`, because DVX's sound library may not
be called from an interrupt.
* *Joysticks* are DVX's, calibrated in its Control Panel: real analog axes.
Two sticks on one game port (a Y cable, or a four-axis stick in DVX's
default two-stick mode) are joysticks 0 and 1. An axis that was never
calibrated sets its range as it moves, so until it is calibrated a full
push can read a little short of 127. DVX's keyboard joystick is not
reported as one.
* *Serial ports* belong to DVX's serial library, one program each. As on
every port, closing one leaves DTR as it is.
* A program's `main` is compiled as `jlGameMain`, which the port calls with
`argv[0]` the `.app`'s path.
`make dvx` builds every example as a DVX program (the `dvx` installer
component supplies DVX and its SDK, <<_setting_up_the_toolchains>>), and
`./joey-run --port dvx NAME` starts one in DVX under DOSBox Staging.