joeylib2/docs/Assets.adoc
2026-10-13 14:01:46 -05:00

349 lines
12 KiB
Text

== Creating Assets
JoeyLib does not load PNGs, WAVs or MODs at run time. Every asset is converted
on the build host into a compact binary the target can use directly, which is
what keeps loading fast and the runtime small. This chapter covers each asset
type: what you author, which tool bakes it, and which API call loads it.
=== The Pipeline at a Glance
[cols="2,2,2,3", options="header"]
|===
|You author |Tool |Baked file |Loaded by
|Indexed PNG tile sheet
|`assetbake.py --type tile`
|`.tbk`
|`jlTileBankLoad`, `jlTileBankLoadToSurface`
|Indexed PNG sprite sheet
|`assetbake.py --type sprite`
|`.spr`
|`jlSpriteBankLoad`
|A `.spr`
|`spritebake`
|`.spc`
|`jlSpriteBankLoadPrecompiled`
|Text note notation
|`songbake.py`
|`.jym` or a C header
|`jlMusicPlay`
|Protracker `.MOD`
|`joeymod`
|`.mod`, `.amod`, `.ntp`
|`jlAudioPlayMod`
|Raw signed 8-bit PCM
|(none -- copied)
|`.sfx`
|`jlAudioPlaySfx`
|===
Baked assets are staged into `DATA/` beside the executable, and the program
opens them with bare names.
=== Graphics: Tiles and Sprites
==== Authoring the PNG
Both tile and sprite sheets start as an *indexed* PNG with strict rules:
* Mode `P` (indexed / palette), *not* RGB or RGBA.
* At most *16 colours*.
* Width and height both *multiples of 8*.
* Palette entry 0 is the transparent / background colour. Remember the library
forces it to black on screen.
Any decent pixel editor can produce this. In GIMP: *Image > Mode > Indexed*,
"Generate optimum palette" with maximum 16 colours, and no dithering --
dithering invents colours and will push you over the limit.
Lay the artwork out on a grid. A tile sheet is read as 8x8 cells row by row; a
sprite sheet is read as cells of whatever size you declare with `--cell`, also
row by row.
==== Baking Tiles
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type tile --target atarist \
font.png build/atarist/bin/DATA/font.tbk
----
Tile banks are *per-target*, because the 8x8 block layout is the platform's
native pixel format -- plane-major on Amiga and X68000, row-major-with-planes-
per-row on ST, chunky 4bpp on DOS and IIgs. You must bake one `.tbk` per platform, and the
loader refuses a file baked for a different target rather than drawing garbage.
`--target` is required for tiles, and accepts `amiga`, `atarist`, `dos`,
`iigs` or `x68000`.
==== Baking Sprites
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type sprite --cell 2x3 \
player.png build/atarist/bin/DATA/player.spr
----
`--cell` gives the cel size *in tiles, not pixels*, so `2x3` means 16x24
pixels. The sheet is cut into cels row by row.
Sprite `.spr` banks are *cross-target*: the same file works on every platform,
because the sprite walker reads chunky 4bpp and converts at draw time. Bake
once, ship everywhere.
Both formats can embed the 16-entry `$0RGB` palette, and both loaders will hand
it back through their `outPalette` parameter so artwork carries its own colours
rather than depending on whatever the stage happens to have set.
==== Pre-Compiling Sprites
A `.spr` still needs compiling into native blitter code at run time. For a
large bank that is a visible startup pause. `spritebake` does it offline:
[source,console]
----
$ build/tools/spritebake-atarist player.spr player.spc
----
The result is a *per-platform* `.spc` holding pre-emitted native routines.
`jlSpriteBankLoadPrecompiled` copies them straight into the codegen arena and
skips compilation entirely. A cel too big for the arena silently loads
uncompiled rather than failing, so this is a safe drop-in replacement.
The `spritebake` binaries are built per target by `make/tools.mk`, because each
one links the emitter for its own CPU while running on the build host.
There is a third option if you would rather ship only the small cross-platform
`.spr`: compile once on first run and cache the result with
`jlSpriteBankSavePrecompiled`, then load the cache on later runs. That trades a
one-time startup cost for a much smaller distribution.
=== Music: the Portable Chip Tracker
This is the recommended music path. One authored song plays on every port
at register-write cost, with no CPU mixing -- which matters enormously on the
ST, where `.MOD` playback is only marginally viable.
Songs are plain text, one row per line:
[source]
----
; Four bars of Am - F - C - G.
tickms 110
loop
; voice0 voice1 voice2 noise
A-2:2 A-4:5 --- N16:5
--- C-5:5 E-5:3 off
A-3:3 E-4:5 off N2:7
--- A-4:5 C-5:3 off
----
The columns are three tone voices plus an optional fourth noise column.
[cols="1,4", options="header"]
|===
|Cell |Meaning
|`C-4:3`
|Note on: note name, octave, then `:` and an attenuation 0..14 where 0 is
loudest. Sharps use `#`, as in `C#4:3`. Range `C-0` to `B-7`. The `:atten`
part may be omitted and defaults to 0.
|`@440:3`
|Note on at an exact frequency in Hz, for chip-accurate transcription where
equal-tempered note names would round the pitch.
|`---`
|Hold: this voice is unchanged for this row.
|`off`
|Silence this voice.
|`N4:3`
|Noise on (fourth column only): pitch 0..31, where 0 is the brightest hiss
and 31 the lowest rumble, plus attenuation.
|===
Directives: `tickms N` sets milliseconds per row and is required before the
first row (later occurrences change tempo mid-song); `loop` marks the next row
as the loop-back target; `repeat N` ... `endrepeat` repeats a block, and may be
nested.
Bake it either as a binary blob or directly as a C array:
[source,console]
----
$ python3 tools/songbake/songbake.py song.song song.jym
$ python3 tools/songbake/songbake.py -c gDemoSong song.song song.h
----
The C header form is usually what you want for a small song: it compiles into
the program and needs no file I/O at all.
[WARNING]
====
On the Atari ST the noise generator shares channel C's volume register with
tone voice 2. Author noise hits and voice-2 notes so they do not sound at the
same time; `songbake` warns when they overlap.
====
=== Music: Protracker Modules
For richer music, author a four-channel `.MOD` in any tracker (ProTracker,
MilkyTracker, OpenMPT). Then convert per target:
[source,console]
----
$ tools/joeymod/joeymod song.mod song.mod # DOS and ST: validated copy
$ tools/joeymod/joeymod song.mod song.amod # Amiga
$ tools/joeymod/joeymod song.mod song.ntp # Apple IIgs
----
The output extension selects the conversion:
* *`.mod`* -- a passthrough copy with the signature validated. DOS and ST
detect song end inside libxmp-lite, so the file is used as-is.
* *`.amod`* -- the same, plus an `E8FF` stop marker injected at song end. The
Amiga replayer, PTPlayer, has no native song-end signal, so without this
marker `jlAudioPlayMod(..., loop=false)` loops anyway. Other replayers ignore
`E8FF`, so an `.amod` is harmless if it reaches the wrong port.
* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, produced by the
NinjaTrackerPlus converter the installer's `ntp` component provides (part of
`toolchain:iigs`; it needs the PHP command-line interpreter, which the
installer's host packages include).
Keep modules modest. A four-channel mix is real CPU work on DOS and the ST, and
the sample data has to fit in memory alongside your game.
=== Sound Effects
Sound effects are *raw signed 8-bit PCM* with no header at all -- just sample
bytes. Convert from a WAV with any audio editor, or on the command line:
[source,console]
----
$ sox input.wav -r 11025 -c 1 -b 8 -e signed-integer output.sfx
----
Then play it, telling the library what rate it was recorded at:
[source,console]
----
jlAudioPlaySfx(4, sfxBytes, sfxLen, 11025);
----
Choose a rate that suits the machines: 8 to 11 kHz is plenty for game effects
and keeps the data small, which matters when the whole thing must fit on a
floppy.
Two things to know. Prefer *slot 4* if music is playing, because the IIgs maps
tracker tone voices onto slots 0 to 2 and the noise voice onto slot 3. And the
IIgs plays a sample value of -128 as -127, because its DOC reads the
corresponding byte as a wave-end marker -- one level out of 256, inaudible in
practice, but it explains a tiny waveform difference if you ever measure one.
For long sounds such as speech, use `jlAudioPlaySfxStream` with a fill callback
instead of holding the whole sample in memory.
=== Screens and Backgrounds
A whole 320x200 screen can be saved and reloaded in target-native form:
[source,c]
----
jlSurfaceSaveFile(surface, "title.sur");
jlSurfaceLoadFile(surface, "title.sur");
----
The file is 32000 bytes of pixels, 200 SCB bytes and 512 bytes of palette. It
is *not* portable between platforms -- no format conversion happens, so the
machine that wrote it must be the machine that reads it. It is best used as a
runtime cache (a generated title screen, say), not as a shipped asset. For
shipped artwork, bake a tile bank and compose the screen with
`jlTileBankLoadToSurface`.
=== Staging Assets into the Build
Baked files must end up in `DATA/` next to the executable. A program's
Makefile lists them in its `ID_DATA` and makes them with rules of its own,
written once for every target (<<_data_files>>). The simple copy case, from
JoeyLib's `make/examples.mk`:
[source,makefile]
----
AUDIO_DATA := $(addprefix $(call programDataDir,AUDIO)/,test.mod test.sfx)
$(call programDataDir,AUDIO)/%: $(REPO_DIR)/assets/%
@mkdir -p $(dir $@)
cp $< $@
----
And a bake rule looks like:
[source,makefile]
----
$(call programDataDir,MYGAME)/font.tbk: assets/font.png
@mkdir -p $(dir $@)
python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type tile --target $(ASSET_PLATFORM) $< $@
----
Tile banks and `.spc` files differ per platform, while `.spr`, `.jym` and
`.sfx` files are shared: `ASSET_PLATFORM` names the target a bake is for (DVX,
SDL3 and the web take the DOS bakes; the last two use only a `.spc`'s cels, not
their code) and `SPRITEBAKE` the matching host `spritebake`.
`examples/tilebank` is a working reference: one rule in `make/examples.mk`
bakes the same PNG for each target into its `DATA/tilebank` (`TILEBANK_DATA`),
and the program loads that bank both ways and shows the loader refusing the
other targets' bakes. Its `scripts/verify-<port>-tilebank.sh` gates run it on
every port. Space Taxi, a game built on JoeyLib in a repository of its own,
has the full-sized one in its `program.mk`: levels extracted from a C64 image,
a cel bank generated by a host tool of its own and pre-compiled per target,
per-level banks marked optional, and speech baked with per-target flags.
=== Loading Assets at Run Time
[source,c]
----
// Tiles into a caller-owned array.
jlTileT tiles[256];
bool valid[256];
uint16_t palette[SURFACE_COLORS_PER_PALETTE];
uint16_t n = jlTileBankLoad("font.tbk", tiles, 256, valid, palette);
// Or stream a big bank straight onto a surface, with no caller buffer.
uint16_t m = jlTileBankLoadToSurface("font.tbk", fontSurface, palette);
// Sprites, pre-compiled.
jlSpriteT *cels[32];
uint16_t count = jlSpriteBankLoadPrecompiled("player.spc", cels, 32, palette);
// Apply the palette the artwork shipped with.
jlPaletteSet(stage, 0, palette);
----
Prefer `jlTileBankLoadToSurface` for anything large. A 1000-tile font sheet is
32000 bytes, which no IIgs program can hold in a local array or in bank-0 BSS,
and the streaming loader needs only a small internal scratch buffer.
=== The Logo
The JoeyLib logo lives in `assets/` as both the original 320x200 PNG and a
traced SVG. If the source art ever changes, regenerate the vector with:
[source,console]
----
$ python3 -m venv /tmp/tracevenv
$ /tmp/tracevenv/bin/pip install potracer pillow numpy
$ /tmp/tracevenv/bin/python tools/traceLogo.py \
assets/joeylib_logo_-_320x200.png assets/joeylibLogo.svg --colors 5
----
Five colour clusters measured as the closest match to the original artwork;
more clusters start carving the antialiased edges into separate layers, which
renders as noise rather than detail.