joeylib2/docs/Assets.adoc

524 lines
19 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`
|A Tiled map (TMX or TMJ) with indexed tilesets
|`tiled2joey.py`, then `assetbake.py --type tile` for its sheet
|a `.map` per layer, and a `.tbk`
|`jlTileMapPaste` (the map read with `jlDataOpen`)
|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`
|A tracker song (Furnace) for the SN76489 or AY-3-8910, exported as VGM
|`vgm2song.py`, then `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.
=== Content Creation Tools
These are the tools we recommend for making each kind of asset. All of them
run on Linux, and all but OpenMPT are native there.
[cols="2,4,3", options="header"]
|===
|You make |With |Notes
|Tile and sprite sheets
|*GrafX2* (GPLv2), a Deluxe Paint-style editor built around an indexed
palette. *LibreSprite* (GPLv2), for sprite sheets drawn as layers and
animation frames. *GIMP* (GPLv3), to bring existing art down to an indexed
PNG.
|In GrafX2, set the palette's RGB scale to 16 (right-click the palette
button for the secondary palette menu): every colour you pick is then one a
JoeyLib palette holds exactly, 4 bits a gun. The sheet rules are in
<<_authoring_the_png>>.
|Tile maps
|*Tiled* (GPLv2; what you make with it is yours), a map editor that places
tiles from tilesets in layers.
|Orthogonal maps of 8x8 tiles from indexed tilesets, then `tiled2joey.py`
(<<_maps_from_tiled>>).
|Program icons
|Any of the above, saving an RGBA PNG with transparency.
|One picture for every system (<<_icons>>).
|Chip-tracker music
|*Furnace* (GPLv2 or later), a chiptune tracker that emulates the chips it
writes for. `./install.sh install furnace` puts it in
`toolchains/opt/furnace`.
|Write for the TI SN76489 or the AY-3-8910, export VGM, and convert it
(<<_writing_songs_in_furnace>>).
|`.MOD` music
|*MilkyTracker* (GPLv3), the *ProTracker 2 clone* (open source), or
*OpenMPT* (BSD; Windows, runs under Wine).
|Four channels, then `joeymod` (<<_music_protracker_modules>>).
|Sound effects
|*sfxr* (MIT) or *jfxr* (open source, in a browser) for generated game
sounds; *Audacity* (GPLv3) to edit recordings; *SoX* to convert.
|Save a WAV, then convert it to raw signed 8-bit PCM
(<<_sound_effects>>).
|===
None of these is needed to build a program: the baked files are what the
build uses. Only Furnace has an installer component, because
`scripts/check-vgm.sh` uses it to test the converter on real songs.
=== 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, and 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`.
==== Maps from Tiled
`tools/tiled2joey.py` turns a map made in Tiled into what a JoeyLib program
pastes: a tile sheet, which `assetbake.py` bakes into a tile bank for each
port, and a map for each tile layer, which `jlTileMapPaste` takes as it is:
[source,console]
----
$ python3 tools/tiled2joey.py --header build/level1.h level1.tmx \
build/level1.png build/level1
tiled2joey: 60x25 map, 87 tiles, 14 colours, layers: ground.map, detail.map
$ python3 tools/assetbake/assetbake.py --type tile --target iigs \
build/level1.png build/iigs/bin/DATA/level1.tbk
----
* *The map* is Tiled's own TMX or its JSON (TMJ), orthogonal and finite, with
8x8 tiles. Its tilesets, in the map or in files of their own (TSX or TSJ),
are single images: indexed PNGs whose colours make one palette of at most
16 between them. The first tileset's colours keep their indices, so its
colour 0 stays the background; another's are matched by colour or take a
slot the first does not use.
* *The sheet* holds the tiles the map's layers use, in the order they first
appear, so the bank holds nothing the map does not place. A tile drawn
flipped or rotated is baked as a tile of its own (JoeyLib draws tiles only
as they are), so a map can use up to 255 distinct tiles and orientations
between its layers.
* *Each layer* becomes `NAME.map` in the folder given: width x height bytes,
row after row, each a tile's index in the bank or `JL_TILE_MAP_SKIP` where
the layer is empty, so pasting a layer over another leaves the one below
showing through. The files are the same on every port.
* `--header` writes the map's size and tile count as C constants, and lists
each layer's file. Object layers, image layers, tile animations, and tile
properties are not carried over.
A map no bigger than the screen pastes in one call. A bigger one -- a level
the program shows a screen of at a time -- pastes a row of tiles a call,
starting each row at its place in the whole map:
[source,c]
----
for (row = 0; row < 25u; row++) {
jlTileMapPaste(jlStageGet(), 0, (uint8_t)row, 40, 1, tiles, &level[(top + row) * LEVEL1_WIDTH + left]);
}
----
A level that scrolls is a tile layer over the whole map (see
<<_scrolling_tile_layers>>). A layer is opaque and draws `JL_TILE_MAP_SKIP` as
colour 0, so it scrolls one map layer; flatten the scenery into one layer in
Tiled.
[source,c]
----
layer = jlTileLayerCreate(tiles, level, LEVEL1_WIDTH, LEVEL1_HEIGHT, 0, 25);
----
==== 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 it holds chunky 4bpp tiles and each port converts a sprite to its own
form when the sprite is created. 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 on the machines' sound chips -- 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.
====
==== Writing Songs in Furnace
A tracker is easier than a text file for anything longer than a jingle. Write
the song in Furnace, export it as VGM (a log of the chip's register writes as
the song plays), and `tools/vgm2song.py` turns that into a `.song` file, which
is still plain text you can read and touch up:
. Start the song on a system with a *TI SN76489* (the Sega Master System, BBC
Micro, or TI-99/4A) or an *AY-3-8910 / YM2149* (the ZX Spectrum, MSX, or
Atari ST's chip). The SN76489 is the closer match: three square tone voices
and a noise channel with its own volume, attenuated in 2 dB steps exactly as
JoeyLib's are. Use that one chip only; another chip's part (a Master System
FM unit, say) is left out.
. Export: *File > Export > VGM*, or `furnace -vgmout song.vgm song.fur` from a
shell.
. Convert and bake:
+
[source,console]
----
$ python3 tools/vgm2song.py song.vgm song.song
$ python3 tools/songbake/songbake.py -c gSong song.song song.h
----
What comes across:
* *Tones* at their exact frequencies (`@Hz` cells) and volumes; an AY volume
`v` becomes attenuation `15 - v`, exact on the ST.
* *Noise* at the nearest JoeyLib noise pitch. The SN76489's periodic noise (a
pitched buzz, the Master System's bass trick) plays as a square at its
pitch on a tone voice that is free, and as noise when all three are busy.
* *The AY's volume envelope*, followed row by row. An envelope repeating
faster than two rows is a timbre (the "buzzer"), not a volume: it plays at
full volume, and a channel whose tone is off sounds at the envelope's
frequency.
* *Timing.* One `.song` row a tick, by default the song's update interval
rounded down (16 ms at 60 Hz, 20 ms at 50 Hz), placed by the VGM's own clock,
so the length and the loop point are right to within a tick.
* *The loop point*, if the song has one.
A baked song must stay under 64 KB. A long, busy song that would not fit gets
a longer tick, a whole multiple of the default, which merges changes faster
than it (vibrato, arpeggios); `--tick MS` sets the tick yourself. The
converter warns about everything it approximates.
=== Music: Protracker Modules
For richer music, author a four-channel `.MOD` in any tracker (ProTracker,
MilkyTracker, OpenMPT) -- one whose signature is `M.K.`, `M!K!`, `FLT4`, or
`4CHN`; the other module formats and the 15-sample Soundtracker files are not
played. Every port plays its effects as Protracker 2.3 does. Then convert
per target:
[source,console]
----
$ tools/joeymod/joeymod song.mod song.mod # every port but the IIgs
$ tools/joeymod/joeymod song.mod song.amod # Amiga, with an end marker
$ tools/joeymod/joeymod song.mod song.ntp # Apple IIgs
----
The output extension selects the conversion:
* *`.mod`* -- a passthrough copy with the signature validated, played as it
is on every port but the IIgs. The ports that mix modules in software
(DOS, the ST, the X68000, and the hosted ports) find the song's end
themselves, and the Amiga adds an end marker as it copies the module to
chip RAM, so `+jlAudioPlayMod(..., loop=false)+` plays the song once.
* *`.amod`* -- the same, plus an `E8F` stop marker added at song end ahead of
time. The Amiga replayer, PTPlayer, has no song-end signal of its own and
needs the marker, but the Amiga adds it itself when a song is played with
`loop=false`, so a plain `.mod` works there too. A module with all 128 song
positions or all 128 patterns in use has no room for the marker either way,
and loops on the Amiga. The other ports' module player stops a song that
does not loop at the marker too, and the IIgs's replayer ignores `E8F`, so
an `.amod` plays the same anywhere (looping, with the marker's pattern of
silence at its end).
* *`.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, the ST, and
the X68000, 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, as are the `.map` files `tiled2joey.py` writes:
`ASSET_PLATFORM` names the target a bake is for (DVX and every hosted port --
SDL3, the web, macOS, Win32, Win16, the PSP, Haiku, and BeOS -- take the DOS
bakes; DVX and Win16 run the x86 code of a `.spc`, the others use only its
cels; the STe build takes the ST's) 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[JL_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.