diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45ec5d1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.mod filter=lfs diff=lfs merge=lfs -text +*.sfx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 9f723ad..2f80e80 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,7 @@ build/ *.bin # Toolchain installations (everything fetched/built by install.sh) -toolchains/iigs/* -toolchains/amiga/* -toolchains/atarist/* -toolchains/dos/* -toolchains/emulators/* -toolchains/.install_state/* -toolchains/cache/* +toolchains/* # Disk images *.adf diff --git a/README.md b/README.md index 0f54bd4..cc5dde9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ make ``` This builds `libjoey.a` for every target whose toolchain is installed, -plus the `examples/hello` program for each. +plus the example programs (`hello`, `pattern`, `keys`, `joy`, `sprite`, +`audio`) for each. ## Building for a single target @@ -49,7 +50,8 @@ src/core/ portable library code src/codegen/ runtime sprite codegen (per-CPU emitters) src/port// per-platform HAL implementations src/shared68k/ assembly shared by Amiga and Atari ST -tools/joeytool/ asset pipeline and packaging +tools/joeyasset/ sprite and tile asset converter +tools/joeymod/ Protracker .MOD converter (passthrough or .NTP) examples/ example programs toolchains/ self-contained cross-build tools make/ per-target Makefile fragments diff --git a/assets/test.mod b/assets/test.mod new file mode 100644 index 0000000..bdf6d3f --- /dev/null +++ b/assets/test.mod @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fb598cb5c8f1c9086e06d02579e1a606eb0ce24483f04019899c161456af1f6 +size 2140 diff --git a/assets/test.sfx b/assets/test.sfx new file mode 100644 index 0000000..4eee896 --- /dev/null +++ b/assets/test.sfx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc4baeab5b9ee67067b7dbb31102c98b26f3b80708f1c0ba027f8af692ab38c6 +size 256 diff --git a/examples/audio/audio.c b/examples/audio/audio.c new file mode 100644 index 0000000..0743c8e --- /dev/null +++ b/examples/audio/audio.c @@ -0,0 +1,120 @@ +// Audio demo: starts an embedded .MOD on entry, triggers a short +// digital SFX every time SPACE is tapped, and exits on ESC. The MOD +// and SFX bytes live in test_assets.h, generated from assets/test.mod +// and assets/test.sfx by the audio-asset build pipeline. +// +// On platforms where the audio HAL is still a stub (DOS / ST / IIgs +// at the moment), joeyAudioInit returns false, every audio call is a +// quiet no-op, and the demo runs as a silent input loop -- you can +// confirm the build links and the input plumbing still works without +// hearing anything. + +#include + +#include + +#include "test_assets.h" + +#define SFX_SLOT 0 +#define SFX_RATE_HZ 8000 + +#define COLOR_BG 0 +#define COLOR_HINT 1 +#define COLOR_BAR 2 + +#define BAR_X 16 +#define BAR_Y 88 +#define BAR_W (SURFACE_WIDTH - 32) +#define BAR_H 16 + + +static void buildPalette(SurfaceT *screen) { + uint16_t colors[SURFACE_COLORS_PER_PALETTE]; + uint16_t i; + + for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) { + colors[i] = 0x0000; + } + colors[COLOR_BG] = 0x0000; + colors[COLOR_HINT] = 0x0444; + colors[COLOR_BAR] = 0x00F0; + + paletteSet(screen, 0, colors); +} + + +// Visual feedback for "audio is running, but you cannot tell on a +// stub HAL": pulse a horizontal bar between the hint color and the +// active color whenever SFX has fired this frame. +static void initialPaint(SurfaceT *screen, bool audioOk) { + surfaceClear(screen, COLOR_BG); + fillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, + audioOk ? COLOR_HINT : COLOR_BG); + surfacePresent(screen); +} + + +int main(void) { + JoeyConfigT config; + SurfaceT *screen; + bool audioOk; + int16_t flashFrames; + + config.hostMode = HOST_MODE_TAKEOVER; + config.codegenBytes = 32 * 1024; + config.maxSurfaces = 4; + config.audioBytes = 64 * 1024; + config.assetBytes = 128 * 1024; + + if (!joeyInit(&config)) { + fprintf(stderr, "joeyInit failed: %s\n", joeyLastError()); + return 1; + } + + screen = surfaceGetScreen(); + if (screen == NULL) { + fprintf(stderr, "surfaceGetScreen returned NULL\n"); + joeyShutdown(); + return 1; + } + + audioOk = joeyAudioInit(); + if (audioOk) { + joeyAudioPlayMod(gTestMod, gTestMod_len, true); + } + + buildPalette(screen); + scbSetRange(screen, 0, SURFACE_HEIGHT - 1, 0); + initialPaint(screen, audioOk); + + flashFrames = 0; + for (;;) { + joeyWaitVBL(); + joeyInputPoll(); + joeyAudioFrameTick(); + if (joeyKeyPressed(KEY_ESCAPE)) { + break; + } + if (joeyKeyPressed(KEY_SPACE)) { + joeyAudioPlaySfx(SFX_SLOT, gTestSfx, gTestSfx_len, SFX_RATE_HZ); + flashFrames = 8; + } + + if (flashFrames > 0) { + fillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, COLOR_BAR); + surfacePresentRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H); + flashFrames--; + if (flashFrames == 0) { + fillRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H, COLOR_HINT); + surfacePresentRect(screen, BAR_X, BAR_Y, BAR_W, BAR_H); + } + } + } + + if (audioOk) { + joeyAudioStopMod(); + joeyAudioShutdown(); + } + joeyShutdown(); + return 0; +} diff --git a/examples/audio/test_assets.h b/examples/audio/test_assets.h new file mode 100644 index 0000000..ab09d16 --- /dev/null +++ b/examples/audio/test_assets.h @@ -0,0 +1,169 @@ +// Generated by examples/audio/build (do not edit by hand). +// Source assets: assets/test.mod, assets/test.sfx. +// +// .MOD is consumed by Amiga/DOS/ST audio HALs directly. The IIgs +// HAL takes a .NTP blob -- a separate header for the IIgs build +// will be generated by joeymod once the IIgs port is wired up. + +#ifndef JOEY_AUDIO_TEST_ASSETS_H +#define JOEY_AUDIO_TEST_ASSETS_H + +static const unsigned char gTestMod[] = { + 0x4a, 0x6f, 0x65, 0x79, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x54, 0x65, 0x73, 0x74, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x73, 0x71, 0x75, 0x61, 0x72, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4d, 0x2e, 0x4b, 0x2e, 0x03, 0x58, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xfa, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xa6, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x80, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x58, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xa6, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x80, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xfa, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x7f, + 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, +}; +static const unsigned int gTestMod_len = 2140; + +static const unsigned char gTestSfx[] = { + 0x00, 0x31, 0x5a, 0x74, 0x7c, 0x71, 0x53, 0x29, 0xf9, 0xca, 0xa4, 0x8d, 0x88, 0x96, 0xb5, 0xdf, + 0x0d, 0x3a, 0x5d, 0x71, 0x73, 0x63, 0x43, 0x19, 0xec, 0xc2, 0xa2, 0x91, 0x92, 0xa4, 0xc4, 0xee, + 0x19, 0x41, 0x5e, 0x6c, 0x69, 0x55, 0x34, 0x0b, 0xe2, 0xbc, 0xa2, 0x97, 0x9d, 0xb2, 0xd3, 0xfb, + 0x23, 0x46, 0x5d, 0x65, 0x5d, 0x47, 0x26, 0x00, 0xd9, 0xb9, 0xa5, 0x9f, 0xa8, 0xc0, 0xe1, 0x06, + 0x2a, 0x48, 0x59, 0x5d, 0x52, 0x39, 0x18, 0xf5, 0xd3, 0xb8, 0xa9, 0xa8, 0xb5, 0xcd, 0xee, 0x10, + 0x30, 0x48, 0x54, 0x53, 0x45, 0x2c, 0x0d, 0xec, 0xcf, 0xb9, 0xaf, 0xb2, 0xc1, 0xda, 0xf9, 0x17, + 0x33, 0x46, 0x4e, 0x49, 0x39, 0x20, 0x03, 0xe6, 0xcd, 0xbc, 0xb6, 0xbc, 0xcd, 0xe5, 0x01, 0x1d, + 0x34, 0x42, 0x46, 0x3f, 0x2d, 0x15, 0xfb, 0xe1, 0xcd, 0xc0, 0xbe, 0xc7, 0xd8, 0xef, 0x08, 0x20, + 0x33, 0x3d, 0x3d, 0x34, 0x23, 0x0c, 0xf5, 0xdf, 0xcf, 0xc6, 0xc7, 0xd1, 0xe2, 0xf8, 0x0e, 0x21, + 0x30, 0x36, 0x34, 0x2a, 0x19, 0x04, 0xf0, 0xdf, 0xd2, 0xcd, 0xd1, 0xdb, 0xec, 0xff, 0x11, 0x21, + 0x2b, 0x2f, 0x2a, 0x20, 0x10, 0xff, 0xee, 0xe0, 0xd7, 0xd5, 0xda, 0xe5, 0xf4, 0x03, 0x12, 0x1f, + 0x26, 0x26, 0x21, 0x17, 0x09, 0xfb, 0xee, 0xe3, 0xde, 0xde, 0xe3, 0xed, 0xfa, 0x06, 0x12, 0x1b, + 0x1f, 0x1e, 0x18, 0x0f, 0x04, 0xf9, 0xef, 0xe8, 0xe5, 0xe6, 0xec, 0xf4, 0xfe, 0x07, 0x10, 0x15, + 0x17, 0x15, 0x10, 0x09, 0x00, 0xf9, 0xf2, 0xee, 0xed, 0xef, 0xf3, 0xfa, 0x00, 0x07, 0x0c, 0x0f, + 0x0f, 0x0d, 0x09, 0x04, 0xff, 0xfa, 0xf6, 0xf5, 0xf5, 0xf7, 0xfa, 0xfe, 0x01, 0x04, 0x07, 0x08, + 0x07, 0x06, 0x03, 0x01, 0xff, 0xfd, 0xfc, 0xfc, 0xfd, 0xfe, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, +}; +static const unsigned int gTestSfx_len = 256; + +#endif diff --git a/include/joey/audio.h b/include/joey/audio.h new file mode 100644 index 0000000..bf249f9 --- /dev/null +++ b/include/joey/audio.h @@ -0,0 +1,73 @@ +// Audio: 4-channel Protracker .MOD music with digital one-shot SFX. +// +// Authors compose music as .MOD modules. The host-side asset pipeline +// (tools/joeymod) converts each module into the runtime form the +// target platform expects: +// +// Apple IIgs -- NinjaTrackerPlus .NTP, played by Ninjaforce's +// 65816 replayer linked into the binary. +// Amiga -- raw .MOD, played by Frank Wille's PTPlayer. +// DOS -- raw .MOD, played by libxmp-lite over SB DMA. +// Atari ST -- raw .MOD, played by libxmp-lite (parser only) plus +// a hand-rolled 68k 4-channel mixer that outputs via +// YM2149 4-bit PWM at ~12.5 kHz. +// +// Game code always calls the same five entry points; the per-port +// HAL hides the engine choice. A failed audio init is non-fatal -- +// joeyInit still succeeds and audio calls become no-ops. + +#ifndef JOEYLIB_AUDIO_H +#define JOEYLIB_AUDIO_H + +#include "platform.h" +#include "types.h" + +#define JOEY_AUDIO_SFX_SLOTS 4 + +// Initialize audio. Returns true if the platform has a working audio +// engine and was able to start it. Returns false silently otherwise; +// the rest of the API stays callable but produces no sound. +bool joeyAudioInit(void); + +// Tear down the engine. Safe to call when audio is not initialized. +void joeyAudioShutdown(void); + +// Begin module playback. data points at the platform-native module +// blob (.MOD on most platforms, .NTP on IIgs); the asset pipeline +// produces the right form for each target. If a module is already +// playing, it is replaced. +// +// loop=true plays forever. loop=false stops at song end, but Amiga +// requires the module to contain an E8FF effect at song end for the +// stop to fire (PTPlayer has no native song-end signal). The +// `joeymod` tool's .amod output extension injects that marker +// automatically; ship .amod for Amiga and .mod for the other ports. +// loop=false on a .mod (no E8FF) loops anyway on Amiga. +void joeyAudioPlayMod(const uint8_t *data, uint32_t length, bool loop); + +// Stop the current module (if any). The playhead is reset so the next +// joeyAudioPlayMod starts from the top. +void joeyAudioStopMod(void); + +// True if a module is currently producing output (false during silence +// after StopMod or before the first PlayMod, and on platforms where +// audio init failed). +bool joeyAudioIsPlayingMod(void); + +// Trigger a one-shot digital SFX on the given slot (0..JOEY_AUDIO_SFX_SLOTS-1). +// sample points at raw signed 8-bit PCM. rateHz is the playback rate +// the sample was recorded at; the engine pitches as needed for its +// own output rate. If the slot is currently playing, the new sample +// replaces it. +void joeyAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz); + +// Stop a SFX slot early. No-op if the slot is already idle. +void joeyAudioStopSfx(uint8_t slot); + +// Hook the engine into the game loop. Most platforms drive their +// engines off a hardware IRQ and ignore this call, but it's safe to +// invoke once per frame regardless and required for any port that +// runs its mixer in user-thread context. +void joeyAudioFrameTick(void); + +#endif diff --git a/include/joey/joey.h b/include/joey/joey.h index d6dce51..f84072d 100644 --- a/include/joey/joey.h +++ b/include/joey/joey.h @@ -15,5 +15,6 @@ #include "draw.h" #include "present.h" #include "input.h" +#include "audio.h" #endif diff --git a/make/amiga.mk b/make/amiga.mk index 74c41aa..1498832 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -7,8 +7,21 @@ BUILD := $(REPO_DIR)/build/$(PLATFORM) LIBDIR := $(BUILD)/lib BINDIR := $(BUILD)/bin -CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_AMIGA -m68000 -fomit-frame-pointer -noixemul -ASFLAGS := -Felf -m68000 -quiet +# PTPlayer is staged by toolchains/install.sh into PTPLAYER_DIR; we +# reference its ptplayer.asm + header from there rather than copying +# them into the source tree, so install.sh can refresh / version it +# independently. -I on $(SRC_PORT)/amiga lets ptplayer.h resolve +# from the port-local shim alongside our HAL code. +PTPLAYER_DIR := $(REPO_DIR)/toolchains/amiga/ptplayer +CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_AMIGA -m68000 -fomit-frame-pointer -noixemul -D__OSCOMPAT -I$(SRC_PORT)/amiga -I$(PTPLAYER_DIR) +# OSCOMPAT=1 selects PTPlayer's audio.device-friendly variant (uses +# CIA-B + audio.device interrupts via the OS rather than taking over +# Paula directly), matching the way our HAL cooperates with Intuition. +# -Fhunk matches Bebbo gcc-amigaos's AmigaOS HUNK object format. ELF +# objects from vasm cannot be linked into a HUNK libjoey.a (the ar +# "file format not recognized" failure mode is silent at archive time +# and surfaces as undefined references at every binary's link step). +ASFLAGS := -Fhunk -m68000 -quiet -DOSCOMPAT=1 # --allow-multiple-definition lets our user-space tzset stub # (src/port/amiga/libinit.c) win over libnix's version in # __gmtoffset.o. libnix's tzset dereferences a possibly-NULL @@ -19,11 +32,18 @@ PORT_C_SRCS := $(wildcard $(SRC_PORT)/amiga/*.c) PORT_S_SRCS := $(wildcard $(SRC_PORT)/amiga/*.s) SHARED_S := $(wildcard $(SRC_68K)/*.s) +# Amiga uses PTPlayer's mt_playfx for SFX, not the libxmp+overlay +# combo that DOS and ST share, so the shared SFX overlay mixer in +# src/core/audioSfxMix.c is unused here. Filter it out to keep dead +# code out of every Amiga binary. +CORE_C_SRCS_AMIGA := $(filter-out %/audioSfxMix.c, $(CORE_C_SRCS)) + LIB_OBJS := \ - $(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS)) \ + $(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS_AMIGA)) \ $(patsubst $(SRC_PORT)/amiga/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ $(patsubst $(SRC_PORT)/amiga/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ - $(patsubst $(SRC_68K)/%.s,$(BUILD)/obj/68k/%.o,$(SHARED_S)) + $(patsubst $(SRC_68K)/%.s,$(BUILD)/obj/68k/%.o,$(SHARED_S)) \ + $(BUILD)/obj/port/ptplayer.o LIB := $(LIBDIR)/libjoey.a @@ -37,9 +57,11 @@ JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/Joy SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c SPRITE_BIN := $(BINDIR)/Sprite +AUDIO_SRC := $(EXAMPLES)/audio/audio.c +AUDIO_BIN := $(BINDIR)/Audio .PHONY: all amiga clean-amiga -all amiga: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) +all amiga: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -53,6 +75,10 @@ $(BUILD)/obj/port/%.o: $(SRC_PORT)/amiga/%.s @mkdir -p $(dir $@) $(AMIGA_AS) $(ASFLAGS) $< -o $@ +$(BUILD)/obj/port/ptplayer.o: $(PTPLAYER_DIR)/ptplayer.asm + @mkdir -p $(dir $@) + $(AMIGA_AS) $(ASFLAGS) $< -o $@ + $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s @mkdir -p $(dir $@) $(AMIGA_AS) $(ASFLAGS) $< -o $@ @@ -81,5 +107,9 @@ $(SPRITE_BIN): $(SPRITE_SRC) $(LIB) @mkdir -p $(dir $@) $(AMIGA_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) +$(AUDIO_BIN): $(AUDIO_SRC) $(LIB) + @mkdir -p $(dir $@) + $(AMIGA_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + clean-amiga: rm -rf $(BUILD) diff --git a/make/atarist.mk b/make/atarist.mk index f0bebf8..354cb37 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -7,10 +7,27 @@ BUILD := $(REPO_DIR)/build/$(PLATFORM) LIBDIR := $(BUILD)/lib BINDIR := $(BUILD)/bin -CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_ATARIST -m68000 -fomit-frame-pointer -ASFLAGS := -Felf -m68000 -quiet +CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_ATARIST -m68000 -fomit-frame-pointer -I$(REPO_DIR)/toolchains/audio/libxmp-lite/include -I$(REPO_DIR)/toolchains/atarist/include-shim +# m68k-atari-mint gcc emits a.out (.o files report "a.out SunOS"), +# not ELF. vasm has to match or `ld: file in wrong format` fires at +# link time. +ASFLAGS := -Faout -m68000 -quiet LDFLAGS := +# libxmp-lite shared with the DOS port. Built as a static archive that +# provides the Protracker pattern decoder for the ST audio HAL; the +# 68k 4-channel mixer + YM2149 PWM output stage layer on top. +LIBXMP_DIR := $(REPO_DIR)/toolchains/audio/libxmp-lite +LIBXMP_SRC := $(filter-out %/win32.c, $(wildcard $(LIBXMP_DIR)/src/*.c) $(wildcard $(LIBXMP_DIR)/src/loaders/*.c)) +LIBXMP_OBJDIR := $(BUILD)/obj/libxmp-lite +LIBXMP_OBJS := $(patsubst $(LIBXMP_DIR)/src/%.c,$(LIBXMP_OBJDIR)/%.o,$(LIBXMP_SRC)) $(LIBXMP_OBJDIR)/math_shim.o +LIBXMP_AR := $(LIBDIR)/libxmplite.a +# LIBXMP_CORE_DISABLE_IT skips the IT-format filter code so libxmp-lite +# doesn't reach for math.h / libm -- mintlib doesn't ship either. We +# target MOD as the authoring format anyway, so dropping IT filter +# support has no impact on JoeyLib's audio. +LIBXMP_CFLAGS := -DLIBXMP_CORE_PLAYER -DLIBXMP_CORE_DISABLE_IT -DHAVE_FNMATCH=0 -I$(LIBXMP_DIR)/include -I$(LIBXMP_DIR)/include/libxmp-lite -I$(LIBXMP_DIR)/src -Wno-unused-parameter + PORT_C_SRCS := $(wildcard $(SRC_PORT)/atarist/*.c) PORT_S_SRCS := $(wildcard $(SRC_PORT)/atarist/*.s) SHARED_S := $(wildcard $(SRC_68K)/*.s) @@ -33,9 +50,11 @@ JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/JOY.PRG SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c SPRITE_BIN := $(BINDIR)/SPRITE.PRG +AUDIO_SRC := $(EXAMPLES)/audio/audio.c +AUDIO_BIN := $(BINDIR)/AUDIO.PRG .PHONY: all atarist clean-atarist -all atarist: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) +all atarist: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -57,25 +76,41 @@ $(LIB): $(LIB_OBJS) @mkdir -p $(dir $@) $(ST_AR) rcs $@ $^ +$(LIBXMP_OBJDIR)/%.o: $(LIBXMP_DIR)/src/%.c + @mkdir -p $(dir $@) + $(ST_CC) $(CFLAGS) $(LIBXMP_CFLAGS) -c $< -o $@ + +$(LIBXMP_OBJDIR)/math_shim.o: $(REPO_DIR)/toolchains/atarist/include-shim/math_shim.c + @mkdir -p $(dir $@) + $(ST_CC) $(CFLAGS) -c $< -o $@ + +$(LIBXMP_AR): $(LIBXMP_OBJS) + @mkdir -p $(dir $@) + $(ST_AR) rcs $@ $^ + $(HELLO_BIN): $(HELLO_SRC) $(LIB) @mkdir -p $(dir $@) - $(ST_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) $(PATTERN_BIN): $(PATTERN_SRC) $(LIB) @mkdir -p $(dir $@) - $(ST_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) $(KEYS_BIN): $(KEYS_SRC) $(LIB) @mkdir -p $(dir $@) - $(ST_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) $(JOY_BIN): $(JOY_SRC) $(LIB) @mkdir -p $(dir $@) - $(ST_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) $(SPRITE_BIN): $(SPRITE_SRC) $(LIB) @mkdir -p $(dir $@) - $(ST_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) + +$(AUDIO_BIN): $(AUDIO_SRC) $(LIB) + @mkdir -p $(dir $@) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) clean-atarist: rm -rf $(BUILD) diff --git a/make/dos.mk b/make/dos.mk index 2f1d8f0..662ede6 100644 --- a/make/dos.mk +++ b/make/dos.mk @@ -7,10 +7,21 @@ BUILD := $(REPO_DIR)/build/$(PLATFORM) LIBDIR := $(BUILD)/lib BINDIR := $(BUILD)/bin -CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_DOS -march=i386 -m32 +CFLAGS := $(COMMON_CFLAGS) -DJOEYLIB_PLATFORM_DOS -march=i386 -m32 -I$(REPO_DIR)/toolchains/audio/libxmp-lite/include ASFLAGS := -f coff LDFLAGS := +# libxmp-lite (MIT, libxmp.org) -- 4-channel-Protracker-and-friends +# replayer + SMIX SFX overlay. Staged at toolchains/audio/libxmp-lite/ +# (currently checked in; install.sh wiring is a follow-up). Built as +# a static archive that the DOS audio HAL links against. +LIBXMP_DIR := $(REPO_DIR)/toolchains/audio/libxmp-lite +LIBXMP_SRC := $(filter-out %/win32.c, $(wildcard $(LIBXMP_DIR)/src/*.c) $(wildcard $(LIBXMP_DIR)/src/loaders/*.c)) +LIBXMP_OBJDIR := $(BUILD)/obj/libxmp-lite +LIBXMP_OBJS := $(patsubst $(LIBXMP_DIR)/src/%.c,$(LIBXMP_OBJDIR)/%.o,$(LIBXMP_SRC)) +LIBXMP_AR := $(LIBDIR)/libxmplite.a +LIBXMP_CFLAGS := -DJOEY_LIBXMP_LITE -DLIBXMP_CORE_PLAYER -DHAVE_FNMATCH=0 -I$(LIBXMP_DIR)/include -I$(LIBXMP_DIR)/include/libxmp-lite -I$(LIBXMP_DIR)/src -Wno-unused-parameter + PORT_C_SRCS := $(wildcard $(SRC_PORT)/dos/*.c) PORT_S_SRCS := $(wildcard $(SRC_PORT)/dos/*.asm) @@ -31,9 +42,11 @@ JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/JOY.EXE SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c SPRITE_BIN := $(BINDIR)/SPRITE.EXE +AUDIO_SRC := $(EXAMPLES)/audio/audio.c +AUDIO_BIN := $(BINDIR)/AUDIO.EXE .PHONY: all dos clean-dos -all dos: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) +all dos: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -51,29 +64,42 @@ $(LIB): $(LIB_OBJS) @mkdir -p $(dir $@) $(DOS_AR) rcs $@ $^ +$(LIBXMP_OBJDIR)/%.o: $(LIBXMP_DIR)/src/%.c + @mkdir -p $(dir $@) + $(DOS_CC) $(CFLAGS) $(LIBXMP_CFLAGS) -c $< -o $@ + +$(LIBXMP_AR): $(LIBXMP_OBJS) + @mkdir -p $(dir $@) + $(DOS_AR) rcs $@ $^ + $(HELLO_BIN): $(HELLO_SRC) $(LIB) @mkdir -p $(dir $@) - $(DOS_CC) $(CFLAGS) $< $(LIB) -o $@ + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ $(PATTERN_BIN): $(PATTERN_SRC) $(LIB) @mkdir -p $(dir $@) - $(DOS_CC) $(CFLAGS) $< $(LIB) -o $@ + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ $(KEYS_BIN): $(KEYS_SRC) $(LIB) @mkdir -p $(dir $@) - $(DOS_CC) $(CFLAGS) $< $(LIB) -o $@ + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ $(JOY_BIN): $(JOY_SRC) $(LIB) @mkdir -p $(dir $@) - $(DOS_CC) $(CFLAGS) $< $(LIB) -o $@ + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ $(SPRITE_BIN): $(SPRITE_SRC) $(LIB) @mkdir -p $(dir $@) - $(DOS_CC) $(CFLAGS) $< $(LIB) -o $@ + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ + $(DOS_EMBED_DPMI) $@ + +$(AUDIO_BIN): $(AUDIO_SRC) $(LIB) + @mkdir -p $(dir $@) + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ clean-dos: diff --git a/make/iigs.mk b/make/iigs.mk index 4843082..aef37be 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -12,9 +12,36 @@ PLATFORM := iigs BUILD := $(REPO_DIR)/build/$(PLATFORM) BINDIR := $(BUILD)/bin -PORT_C_SRCS := $(wildcard $(SRC_PORT)/iigs/*.c) +PORT_C_SRCS_ALL := $(wildcard $(SRC_PORT)/iigs/*.c) -LIB_SRCS := $(CORE_C_SRCS) $(PORT_C_SRCS) +# audio.c is the no-op stub linked into every demo. audio_full.c is the +# real implementation (NewHandle / fopen / JSL trampoline) and links +# only into AUDIO -- the IIgs build is monolithic, so pulling Memory +# Manager + ORCA stdio into every binary blows the linker's +# "Expression too complex" budget. The two files define the same +# halAudio* symbols; iigs/audio.c is filtered out of the AUDIO source +# set, audio_full.c is filtered out of the everyone-else set. +PORT_C_SRCS := $(filter-out %/audio_full.c, $(PORT_C_SRCS_ALL)) +PORT_C_SRCS_AUDIO := $(filter-out %/audio.c, $(PORT_C_SRCS_ALL)) + +# IIgs uses NTPstreamsound for SFX, not the libxmp+overlay combo that +# DOS and ST share, so src/core/audioSfxMix.c is unused here. Filter +# it out so the ORCA Linker doesn't pull its dead code into every +# IIgs binary (the monolithic-link budget is tight). +CORE_C_SRCS_IIGS := $(filter-out %/audioSfxMix.c, $(CORE_C_SRCS)) + +LIB_SRCS := $(CORE_C_SRCS_IIGS) $(PORT_C_SRCS) +LIB_SRCS_AUDIO := $(CORE_C_SRCS_IIGS) $(PORT_C_SRCS_AUDIO) + +# NinjaTrackerPlus replayer. Assembled with Merlin32 from the staged +# source at toolchains/iigs/ntp/ninjatrackerplus.s. Output is a 34 KB +# raw 65816 binary that the IIgs audio HAL loads at runtime via +# Memory Manager into a fixed-bank handle. NTP source is bank-internal +# (no JSL/JML cross-bank jumps) so the player works at any bank-start +# load address even though it was assembled with `org $0F0000`. +NTP_SRC := $(REPO_DIR)/toolchains/iigs/ntp/ninjatrackerplus.s +NTP_BIN := $(BUILD)/audio/ntpplayer.bin +IIGS_MERLIN := $(REPO_DIR)/toolchains/iigs/merlin32/bin/merlin32 HELLO_SRC := $(EXAMPLES)/hello/hello.c HELLO_BIN := $(BINDIR)/HELLO @@ -26,7 +53,14 @@ JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/JOY SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c SPRITE_BIN := $(BINDIR)/SPRITE -DISK_IMG := $(BINDIR)/joey.2mg +AUDIO_SRC := $(EXAMPLES)/audio/audio.c +AUDIO_BIN := $(BINDIR)/AUDIO +AUDIO_MOD := $(REPO_DIR)/assets/test.mod +AUDIO_SFX := $(REPO_DIR)/assets/test.sfx +AUDIO_NTP := $(BUILD)/audio/test.ntp +AUDIO_HEADER := $(BUILD)/audio/test_assets.h +JOEYMOD := $(REPO_DIR)/tools/joeymod/joeymod +DISK_IMG := $(BINDIR)/joey.2mg IIGS_PACKAGE := $(REPO_DIR)/toolchains/iigs/package-disk.sh @@ -37,7 +71,13 @@ IIX_INCLUDES := \ -I $(SRC_CORE) .PHONY: all iigs iigs-disk clean-iigs -all iigs: $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) +all iigs: $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(NTP_BIN) + +$(NTP_BIN): $(NTP_SRC) $(IIGS_MERLIN) + @mkdir -p $(dir $@) + @cp $(NTP_SRC) $(BUILD)/audio/ninjatrackerplus.s + cd $(BUILD)/audio && $(IIGS_MERLIN) . ninjatrackerplus.s + mv $(BUILD)/audio/ntpplayer $@ # iix-build.sh takes MAIN.c first, then EXTRA sources (compiled with # #pragma noroot). The example source supplies main(); libjoey sources @@ -69,13 +109,55 @@ $(SPRITE_BIN): $(SPRITE_SRC) $(LIB_SRCS) $(IIGS_BUILD) $(IIGS_BUILD) $(IIX_INCLUDES) -o $@ $(SPRITE_SRC) $(LIB_SRCS) $(IIGS_IIX) chtyp -t S16 $@ +# IIgs override of test_assets.h: gTestMod[] holds .NTP-converted bytes +# (NinjaTrackerPlus runtime format) instead of raw .MOD. The SFX bytes +# are platform-independent so we just embed the same test.sfx. +# +# .NTP conversion runs joeymod (which shells out to ntpconverter.php). +# If PHP is missing we skip the override entirely and the regular .MOD +# bytes get linked instead -- the IIgs binary still builds and the +# trampoline still calls into NTP, but NTPprepare will reject the MOD +# magic at runtime. Install php-cli to get real audio playback. +HAVE_PHP := $(shell command -v php >/dev/null 2>&1 && echo 1) + +ifeq ($(HAVE_PHP),1) +$(AUDIO_NTP): $(AUDIO_MOD) $(JOEYMOD) + @mkdir -p $(dir $@) + $(JOEYMOD) $< $@ + +$(AUDIO_HEADER): $(AUDIO_NTP) $(AUDIO_SFX) + @mkdir -p $(dir $@) + @echo "// Generated by make/iigs.mk -- gTestMod is .NTP, gTestSfx is raw PCM." > $@ + @echo "#ifndef JOEY_AUDIO_TEST_ASSETS_H" >> $@ + @echo "#define JOEY_AUDIO_TEST_ASSETS_H" >> $@ + @printf "static const unsigned char gTestMod[] = {\n" >> $@ + @xxd -i < $(AUDIO_NTP) >> $@ + @printf "};\nstatic const unsigned int gTestMod_len = %d;\n" $$(wc -c < $(AUDIO_NTP)) >> $@ + @printf "static const unsigned char gTestSfx[] = {\n" >> $@ + @xxd -i < $(AUDIO_SFX) >> $@ + @printf "};\nstatic const unsigned int gTestSfx_len = %d;\n" $$(wc -c < $(AUDIO_SFX)) >> $@ + @echo "#endif" >> $@ + +AUDIO_HEADER_DEP := $(AUDIO_HEADER) +AUDIO_HEADER_FLAGS := -I $(dir $(AUDIO_HEADER)) +else +$(info iigs: php-cli not installed -- AUDIO demo will embed raw .MOD bytes; install php-cli for real IIgs audio playback) +AUDIO_HEADER_DEP := +AUDIO_HEADER_FLAGS := +endif + +$(AUDIO_BIN): $(AUDIO_SRC) $(LIB_SRCS_AUDIO) $(AUDIO_HEADER_DEP) $(IIGS_BUILD) + @mkdir -p $(dir $@) + $(IIGS_BUILD) -b $(IIX_INCLUDES) $(AUDIO_HEADER_FLAGS) -I $(EXAMPLES)/audio -o $@ $(AUDIO_SRC) $(LIB_SRCS_AUDIO) + $(IIGS_IIX) chtyp -t S16 $@ + # Assemble an 800KB ProDOS 2img containing the examples, ready to # mount in GSplus alongside a GS/OS boot volume. iigs-disk: $(DISK_IMG) -$(DISK_IMG): $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(IIGS_PACKAGE) +$(DISK_IMG): $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(NTP_BIN) $(IIGS_PACKAGE) @mkdir -p $(dir $@) - $(IIGS_PACKAGE) $@ $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) + $(IIGS_PACKAGE) $@ $(HELLO_BIN) $(PATTERN_BIN) $(KEYS_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(NTP_BIN) clean-iigs: rm -rf $(BUILD) diff --git a/make/tools.mk b/make/tools.mk index dfe10f6..cee55bd 100644 --- a/make/tools.mk +++ b/make/tools.mk @@ -2,7 +2,10 @@ # # These build with the host's default `cc` (no cross-toolchain) into # build/tools/. Currently: -# joeyasset -- PPM (P6) -> .jas converter +# joeyasset -- PPM (P6) -> .jas converter +# modloopend -- inject an E8FF stop-marker pattern into a .MOD so +# the Amiga audio HAL can honor PlayMod(loop=false). +# joeymod invokes this transparently for Amiga output. include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk @@ -12,15 +15,21 @@ TOOLS_DIR := $(REPO_DIR)/tools HOST_CC ?= cc HOST_CFLAGS := -std=c99 -Wall -Wextra -O2 -JOEYASSET_SRC := $(TOOLS_DIR)/joeyasset/joeyasset.c -JOEYASSET_BIN := $(BUILD_DIR)/joeyasset +JOEYASSET_SRC := $(TOOLS_DIR)/joeyasset/joeyasset.c +JOEYASSET_BIN := $(BUILD_DIR)/joeyasset +MODLOOPEND_SRC := $(TOOLS_DIR)/modloopend/modloopend.c +MODLOOPEND_BIN := $(BUILD_DIR)/modloopend .PHONY: all tools clean-tools -all tools: $(JOEYASSET_BIN) +all tools: $(JOEYASSET_BIN) $(MODLOOPEND_BIN) $(JOEYASSET_BIN): $(JOEYASSET_SRC) @mkdir -p $(dir $@) $(HOST_CC) $(HOST_CFLAGS) $< -o $@ +$(MODLOOPEND_BIN): $(MODLOOPEND_SRC) + @mkdir -p $(dir $@) + $(HOST_CC) $(HOST_CFLAGS) $< -o $@ + clean-tools: rm -rf $(BUILD_DIR) diff --git a/scripts/run-amiga.sh b/scripts/run-amiga.sh index b7e99ff..63e343c 100755 --- a/scripts/run-amiga.sh +++ b/scripts/run-amiga.sh @@ -32,7 +32,8 @@ case $prog in keys) file=Keys ;; joy) file=Joy ;; sprite) file=Sprite ;; - *) echo "usage: $0 [hello|pattern|keys|joy|sprite]" >&2; exit 2 ;; + audio) file=Audio ;; + *) echo "usage: $0 [hello|pattern|keys|joy|sprite|audio]" >&2; exit 2 ;; esac if [[ ! -f "$bin_dir/$file" ]]; then @@ -60,6 +61,7 @@ cp "$bin_dir/Pattern" "$work/" 2>/dev/null || true cp "$bin_dir/Keys" "$work/" 2>/dev/null || true cp "$bin_dir/Joy" "$work/" 2>/dev/null || true cp "$bin_dir/Sprite" "$work/" 2>/dev/null || true +cp "$bin_dir/Audio" "$work/" 2>/dev/null || true # ':' prefix anchors to the root of the current volume; otherwise # AmigaDOS looks in C: and the command is not found. echo ":$file" > "$work/s/startup-sequence" diff --git a/scripts/run-atarist.sh b/scripts/run-atarist.sh index f2a5eec..22b51f8 100755 --- a/scripts/run-atarist.sh +++ b/scripts/run-atarist.sh @@ -20,7 +20,8 @@ case $prog in keys) file=KEYS.PRG ;; joy) file=JOY.PRG ;; sprite) file=SPRITE.PRG ;; - *) echo "usage: $0 [hello|pattern|keys|joy|sprite]" >&2; exit 2 ;; + audio) file=AUDIO.PRG ;; + *) echo "usage: $0 [hello|pattern|keys|joy|sprite|audio]" >&2; exit 2 ;; esac tos=$repo/toolchains/emulators/support/emutos-512k.img diff --git a/scripts/run-dos.sh b/scripts/run-dos.sh index 46021e7..46f3f93 100755 --- a/scripts/run-dos.sh +++ b/scripts/run-dos.sh @@ -17,7 +17,8 @@ case $prog in keys) file=KEYS.EXE ;; joy) file=JOY.EXE ;; sprite) file=SPRITE.EXE ;; - *) echo "usage: $0 [hello|pattern|keys|joy|sprite]" >&2; exit 2 ;; + audio) file=AUDIO.EXE ;; + *) echo "usage: $0 [hello|pattern|keys|joy|sprite|audio]" >&2; exit 2 ;; esac if [[ ! -f "$bin_dir/$file" ]]; then diff --git a/scripts/run-iigs.sh b/scripts/run-iigs.sh index 251e207..5f749bf 100755 --- a/scripts/run-iigs.sh +++ b/scripts/run-iigs.sh @@ -14,6 +14,7 @@ # scripts/run-iigs.sh keys # boots, hints KEYS # scripts/run-iigs.sh joy # boots, hints JOY # scripts/run-iigs.sh sprite # boots, hints SPRITE +# scripts/run-iigs.sh audio # boots, hints AUDIO set -euo pipefail @@ -27,8 +28,8 @@ data_disk=$repo/build/iigs/bin/joey.2mg null_c600=$repo/toolchains/emulators/support/iigs-null-c600.rom case $prog in - hello|pattern|keys|joy|sprite) ;; - *) echo "usage: $0 [hello|pattern|keys|joy|sprite]" >&2; exit 2 ;; + hello|pattern|keys|joy|sprite|audio) ;; + *) echo "usage: $0 [hello|pattern|keys|joy|sprite|audio]" >&2; exit 2 ;; esac for f in "$gsplus" "$rom" "$sys_disk" "$data_disk" "$null_c600"; do diff --git a/src/core/audio.c b/src/core/audio.c new file mode 100644 index 0000000..8cd4824 --- /dev/null +++ b/src/core/audio.c @@ -0,0 +1,80 @@ +// Public audio API: thin pass-through to the per-port HAL plus a +// gate that makes every call safe when audio init failed (or has not +// been called). Game code can fire-and-forget calls regardless of +// whether the platform actually has audio working at runtime. + +#include + +#include "joey/audio.h" +#include "hal.h" + +static bool gAudioReady = false; + + +bool joeyAudioInit(void) { + if (gAudioReady) { + return true; + } + gAudioReady = halAudioInit(); + return gAudioReady; +} + + +void joeyAudioShutdown(void) { + if (!gAudioReady) { + return; + } + halAudioShutdown(); + gAudioReady = false; +} + + +void joeyAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + if (!gAudioReady || data == NULL || length == 0) { + return; + } + halAudioPlayMod(data, length, loop); +} + + +void joeyAudioStopMod(void) { + if (!gAudioReady) { + return; + } + halAudioStopMod(); +} + + +bool joeyAudioIsPlayingMod(void) { + if (!gAudioReady) { + return false; + } + return halAudioIsPlayingMod(); +} + + +void joeyAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + if (!gAudioReady || sample == NULL || length == 0) { + return; + } + if (slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + halAudioPlaySfx(slot, sample, length, rateHz); +} + + +void joeyAudioStopSfx(uint8_t slot) { + if (!gAudioReady || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + halAudioStopSfx(slot); +} + + +void joeyAudioFrameTick(void) { + if (!gAudioReady) { + return; + } + halAudioFrameTick(); +} diff --git a/src/core/audioSfxMix.c b/src/core/audioSfxMix.c new file mode 100644 index 0000000..81318f2 --- /dev/null +++ b/src/core/audioSfxMix.c @@ -0,0 +1,53 @@ +// Shared SFX overlay mixer. See audioSfxMixInternal.h for the contract. + +#include "audioSfxMixInternal.h" + + +void audioSfxOverlayMix(volatile uint8_t *dst, uint16_t len, AudioSfxSlotT *slots, uint8_t numSlots) { + AudioSfxSlotT *slot; + uint32_t pos; + uint32_t step; + uint32_t idx; + uint16_t i; + uint8_t s; + int sample; + int mixed; + + for (s = 0; s < numSlots; s++) { + slot = &slots[s]; + if (!slot->active) { + continue; + } + pos = slot->posFp; + step = slot->stepFp; + for (i = 0; i < len; i++) { + idx = pos >> 16; + if (idx >= slot->length) { + slot->active = false; + break; + } + sample = (int)(int8_t)slot->sample[idx]; + mixed = (int)dst[i] - 128 + sample; + if (mixed > 127) { + mixed = 127; + } else if (mixed < -128) { + mixed = -128; + } + dst[i] = (uint8_t)(mixed + 128); + pos += step; + } + slot->posFp = pos; + } +} + + +void audioSfxSlotArm(AudioSfxSlotT *slot, const uint8_t *sample, uint32_t length, uint16_t rateHz, uint32_t outputRate) { + if (rateHz == 0 || outputRate == 0) { + return; + } + slot->sample = sample; + slot->length = length; + slot->posFp = 0; + slot->stepFp = ((uint32_t)rateHz << 16) / outputRate; + slot->active = true; +} diff --git a/src/core/audioSfxMixInternal.h b/src/core/audioSfxMixInternal.h new file mode 100644 index 0000000..898215c --- /dev/null +++ b/src/core/audioSfxMixInternal.h @@ -0,0 +1,47 @@ +// Shared SFX overlay mixer used by HALs whose engine outputs MOD-only +// (libxmp-lite on DOS + ST). The mixer overlays one-shot signed 8-bit +// PCM samples on top of the engine's already-rendered unsigned 8-bit +// MOD output, with a 16.16 fixed-point step so the SFX rate can +// differ from the output rate without needing an up-front resampling +// pass. +// +// Not used by Amiga (PTPlayer's mt_playfx handles SFX natively) or +// IIgs (NTPstreamsound runs its own DOC-RAM streamer). + +#ifndef JOEYLIB_AUDIO_SFX_MIX_H +#define JOEYLIB_AUDIO_SFX_MIX_H + +#include "joey/audio.h" +#include "joey/types.h" + +typedef struct { + const uint8_t *sample; // signed 8-bit PCM, caller-owned + uint32_t length; // sample bytes + uint32_t posFp; // 16.16 fixed-point read position + uint32_t stepFp; // (rateHz << 16) / outputRate + bool active; +} AudioSfxSlotT; + +// Arm a slot for one-shot playback. `outputRate` is the HAL's mixer +// output rate; the helper computes stepFp from rateHz and outputRate. +// Sets `active = true`. No-op if rateHz is 0. +void audioSfxSlotArm(AudioSfxSlotT *slot, + const uint8_t *sample, + uint32_t length, + uint16_t rateHz, + uint32_t outputRate); + +// Overlay all active slots' contributions on top of `dst[0..len)`. +// `dst` is unsigned 8-bit centered at $80. Mix is signed-to-signed +// against (dst[i] - 128), clamped at +/-127, then biased back. Slots +// whose read position passes the sample end are auto-deactivated. +// +// dst is `volatile` because the ST HAL's ISR consumes the buffer +// from interrupt context; the DOS HAL's dst is non-volatile DMA +// memory but the function accepts both via the wider qualifier. +void audioSfxOverlayMix(volatile uint8_t *dst, + uint16_t len, + AudioSfxSlotT *slots, + uint8_t numSlots); + +#endif diff --git a/src/core/hal.h b/src/core/hal.h index ce0c3d3..c0f205e 100644 --- a/src/core/hal.h +++ b/src/core/hal.h @@ -50,4 +50,18 @@ void halInputPoll(void); // graphics.library WaitTOF, XBIOS Vsync, $C019 polling). void halWaitVBL(void); +// Audio: per-port engine setup, module + SFX playback, teardown. +// halAudioInit returns true if the platform has a working engine. +// All entry points are safe to call when init failed -- they become +// no-ops. See joey/audio.h for the public API contract that wraps +// these. +bool halAudioInit(void); +void halAudioShutdown(void); +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop); +void halAudioStopMod(void); +bool halAudioIsPlayingMod(void); +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz); +void halAudioStopSfx(uint8_t slot); +void halAudioFrameTick(void); + #endif diff --git a/src/port/amiga/SDI_compiler.h b/src/port/amiga/SDI_compiler.h new file mode 100644 index 0000000..052db17 --- /dev/null +++ b/src/port/amiga/SDI_compiler.h @@ -0,0 +1,15 @@ +// Minimal SDI_compiler.h shim. The full SDI suite is a multi-compiler +// macro layer; PTPlayer.h only needs ASM + REG, both of which collapse +// to GCC m68k-amigaos register-binding syntax under Bebbo's compiler. + +#ifndef SDI_COMPILER_H +#define SDI_COMPILER_H + +#ifdef __GNUC__ +#define ASM +#define REG(reg, decl) decl __asm(#reg) +#else +#error "SDI_compiler.h shim: add ASM/REG bindings for this compiler" +#endif + +#endif diff --git a/src/port/amiga/audio.c b/src/port/amiga/audio.c new file mode 100644 index 0000000..8da18f2 --- /dev/null +++ b/src/port/amiga/audio.c @@ -0,0 +1,256 @@ +// Amiga audio HAL: PTPlayer (Frank Wille, public domain) drives Paula +// for both module playback and one-shot SFX overlay. +// +// We build PTPlayer in OSCOMPAT mode so it cooperates with audio.device +// rather than taking Paula away from the OS -- matches our cooperative- +// with-Intuition graphics HAL. mt_install allocates all four Paula +// channels via audio.device; mt_init loads a .MOD; mt_Enable=1 starts +// playback; mt_playfx triggers a SFX on a free channel (or the worst +// channel if all are busy) with priority arbitration. +// +// PTPlayer expects modules and SFX samples to live in Chip RAM. The +// asset pipeline does not yet partition into Chip vs. Fast, so we +// AllocMem(MEMF_CHIP) a copy at PlayMod / PlaySfx time and free it at +// StopMod / StopSfx (or shutdown). + +#include + +#include +#include +#include + +#include + +#include "ptplayer.h" +#include "hal.h" +#include "joey/audio.h" + +extern struct Custom custom; +extern UBYTE mt_Enable; +extern UBYTE mt_E8Trigger; + +// ----- Constants ----- + +// Paula's NTSC PAL period for middle-C-ish playback. PTPlayer's SFX +// API takes a hardware period (cycles per sample) rather than a +// frequency. To convert from a sample's authored Hz: period = clock / +// rate, where clock is ~3546895 (PAL) or ~3579545 (NTSC). We default +// PAL since that's what our screen mode requests, and clamp to legal +// Paula period limits. +#define PAULA_CLOCK_PAL 3546895UL +#define PAULA_PERIOD_MIN 124 // Paula HW limit +#define PAULA_PERIOD_MAX 65535 + +#define SFX_VOLUME_MAX 64 + +// PTPlayer has no built-in "play once" mode -- songs loop forever +// unless the author placed an `E8FF` effect at song end. mt_E8Trigger +// reflects the most-recently-seen E8 value; mt_init resets it to 0. +// When loop=false on PlayMod, we poll for this sentinel in +// halAudioFrameTick and clear mt_Enable when seen. +#define PTPLAYER_LOOP_END_MARKER 0xFF + +// ----- Module state ----- + +typedef struct { + UBYTE *samples; // Chip-RAM copy of one SFX sample + UWORD lengthWords; // sample length in 16-bit words +} SfxSlotT; + +static UBYTE *gModuleChip = NULL; // Chip-RAM copy of the playing module +static ULONG gModuleLength = 0; // bytes to FreeMem on swap / shutdown +static SfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS]; +static bool gInstalled = false; +static bool gModPlaying = false; +static bool gWantLoopOnce = false; // honor loop=false via E8 marker + +// ----- Internal helpers ----- + +static UWORD periodForRate(uint16_t rateHz) { + unsigned long period; + + if (rateHz == 0) { + return 428; // ~middle C, sane default + } + period = PAULA_CLOCK_PAL / (unsigned long)rateHz; + if (period < PAULA_PERIOD_MIN) { + period = PAULA_PERIOD_MIN; + } + if (period > PAULA_PERIOD_MAX) { + period = PAULA_PERIOD_MAX; + } + return (UWORD)period; +} + + +// ----- HAL API (alphabetical) ----- + +bool halAudioInit(void) { + int i; + + if (gInstalled) { + return true; + } + if (mt_install() == 0) { + return false; + } + mt_Enable = 0; + gInstalled = true; + gModPlaying = false; + gWantLoopOnce = false; + gModuleChip = NULL; + gModuleLength = 0; + for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) { + gSfxSlots[i].samples = NULL; + gSfxSlots[i].lengthWords = 0; + } + return true; +} + + +void halAudioShutdown(void) { + int i; + + if (!gInstalled) { + return; + } + mt_Enable = 0; + mt_end((void *)&custom); + mt_remove(); + gInstalled = false; + gModPlaying = false; + + if (gModuleChip != NULL) { + FreeMem(gModuleChip, gModuleLength); + gModuleChip = NULL; + gModuleLength = 0; + } + for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) { + if (gSfxSlots[i].samples != NULL) { + FreeMem(gSfxSlots[i].samples, (ULONG)gSfxSlots[i].lengthWords * 2UL); + gSfxSlots[i].samples = NULL; + gSfxSlots[i].lengthWords = 0; + } + } +} + + +bool halAudioIsPlayingMod(void) { + return gModPlaying && mt_Enable != 0; +} + + +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + UBYTE *chip; + + if (!gInstalled) { + return; + } + mt_Enable = 0; + mt_end((void *)&custom); + gModPlaying = false; + gWantLoopOnce = false; + if (gModuleChip != NULL) { + FreeMem(gModuleChip, gModuleLength); + gModuleChip = NULL; + gModuleLength = 0; + } + + chip = (UBYTE *)AllocMem((ULONG)length, MEMF_CHIP); + if (chip == NULL) { + return; + } + memcpy(chip, data, length); + gModuleChip = chip; + gModuleLength = (ULONG)length; + + // PTPlayer takes the module pointer in a0, sample-data pointer in + // a1 (NULL = samples follow the module header), song position in + // d0. mt_init resets mt_E8Trigger to 0 so subsequent halAudioFrame + // Tick polling sees a clean state. + mt_init((void *)&custom, chip, NULL, 0); + mt_Enable = 1; + gWantLoopOnce = !loop; + gModPlaying = true; +} + + +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + SfxStructure sfx; + SfxSlotT *s; + UBYTE *chip; + UWORD words; + + if (!gInstalled || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + if (length < 2 || length > 0x1FFFEUL) { + return; // Paula sample length is 16 bits of words = 128KB max + } + + s = &gSfxSlots[slot]; + if (s->samples != NULL) { + FreeMem(s->samples, (ULONG)s->lengthWords * 2UL); + s->samples = NULL; + s->lengthWords = 0; + } + + chip = (UBYTE *)AllocMem((ULONG)length, MEMF_CHIP); + if (chip == NULL) { + return; + } + memcpy(chip, sample, length); + words = (UWORD)(length >> 1); + + s->samples = chip; + s->lengthWords = words; + + sfx.sfx_ptr = chip; + sfx.sfx_len = words; + sfx.sfx_per = periodForRate(rateHz); + sfx.sfx_vol = SFX_VOLUME_MAX; + sfx.sfx_cha = -1; // best free channel + sfx.sfx_pri = (BYTE)(slot + 1); // priority 1..N from slot index + mt_playfx((void *)&custom, &sfx); +} + + +void halAudioStopMod(void) { + if (!gInstalled) { + return; + } + mt_Enable = 0; + mt_end((void *)&custom); + gModPlaying = false; + gWantLoopOnce = false; +} + + +void halAudioStopSfx(uint8_t slot) { + UBYTE i; + + if (!gInstalled || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + // PTPlayer addresses SFX by Paula channel (0..3), but mt_playfx + // picks a channel dynamically and does not return which one. We + // can't reliably map slot -> channel after the fact, so stop all + // four channels' SFX state. mt_stopfx is idempotent on idle + // channels, so stopping a quiet one is harmless. + for (i = 0; i < 4; i++) { + mt_stopfx((void *)&custom, i); + } +} + + +void halAudioFrameTick(void) { + // PTPlayer drives itself off CIA-B; the only host-loop work is the + // play-once watchdog. When the song author placed an `E8FF` at + // song end, mt_E8Trigger latches to 0xFF -- if the caller passed + // loop=false to PlayMod, that's our cue to stop the song. + if (gWantLoopOnce && gModPlaying && mt_E8Trigger == PTPLAYER_LOOP_END_MARKER) { + mt_Enable = 0; + gModPlaying = false; + gWantLoopOnce = false; + } +} diff --git a/src/port/amiga/hal.c b/src/port/amiga/hal.c index bd5b9ba..a9c33b8 100644 --- a/src/port/amiga/hal.c +++ b/src/port/amiga/hal.c @@ -52,7 +52,7 @@ extern struct Custom custom; // ----- Prototypes ----- static void buildCopperList(const SurfaceT *src); -static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1); +static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1, uint16_t byteStart, uint16_t byteEnd); static void dumpCopperList(void); static void installCopperList(void); static void uploadFirstBandPalette(const SurfaceT *src); @@ -84,7 +84,11 @@ static bool paletteOrScbChanged(const SurfaceT *src); // Each plane scanline is 40 bytes (1 bit per pixel x 320 pixels). // For each destination byte, 8 pixels' worth of 4bpp chunky source is // read and split into one bit per plane. -static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1) { +// c2p over rows y0..y1 and planar-byte columns byteStart..byteEnd. +// Each planar byte corresponds to 8 horizontal pixels = 4 source +// bytes; partial-rect callers should round byteStart down and byteEnd +// up to keep the 8-pixel alignment. +static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1, uint16_t byteStart, uint16_t byteEnd) { const uint8_t *srcLine; UBYTE *p0; UBYTE *p1; @@ -109,7 +113,7 @@ static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1) { p2 = &gPlanes[2][y * AMIGA_BYTES_PER_ROW]; p3 = &gPlanes[3][y * AMIGA_BYTES_PER_ROW]; - for (planarByte = 0; planarByte < AMIGA_BYTES_PER_ROW; planarByte++) { + for (planarByte = byteStart; planarByte < byteEnd; planarByte++) { b0 = 0; b1 = 0; b2 = 0; @@ -147,7 +151,6 @@ static void buildCopperList(const SurfaceT *src) { UWORD prevPalIdx; UWORD vpos; UWORD topBorder; - UWORD bandCount; ucl = (struct UCopList *)AllocMem(sizeof(struct UCopList), MEMF_PUBLIC | MEMF_CLEAR); @@ -177,7 +180,6 @@ static void buildCopperList(const SurfaceT *src) { // the viewport end. topBorder = 0; prevPalIdx = 0xFFFF; - bandCount = 0; for (line = 0; line < SURFACE_HEIGHT; line++) { palIdx = src->scb[line]; @@ -194,9 +196,7 @@ static void buildCopperList(const SurfaceT *src) { CMOVE(ucl, custom.color[col], src->palette[palIdx][col]); } prevPalIdx = (UWORD)palIdx; - bandCount++; } - (void)bandCount; CEND(ucl); gNewUCL = ucl; @@ -417,19 +417,27 @@ void halPresent(const SurfaceT *src) { return; } updateCopperIfNeeded(src); - c2pRange(src, 0, SURFACE_HEIGHT); + c2pRange(src, 0, SURFACE_HEIGHT, 0, AMIGA_BYTES_PER_ROW); } void halPresentRect(const SurfaceT *src, int16_t x, int16_t y, uint16_t w, uint16_t h) { - (void)x; - (void)w; + uint16_t byteStart; + uint16_t byteEnd; if (src == NULL || gScreen == NULL) { return; } updateCopperIfNeeded(src); - c2pRange(src, y, y + (int16_t)h); + // Each planar byte covers 8 horizontal pixels. Round dirty pixel + // range to the enclosing planar-byte range so we never miss an + // edge pixel while still honoring the rect width. + byteStart = (uint16_t)(x >> 3); + byteEnd = (uint16_t)(((uint16_t)x + w + 7) >> 3); + if (byteEnd > AMIGA_BYTES_PER_ROW) { + byteEnd = AMIGA_BYTES_PER_ROW; + } + c2pRange(src, y, y + (int16_t)h, byteStart, byteEnd); } diff --git a/src/port/atarist/audio.c b/src/port/atarist/audio.c new file mode 100644 index 0000000..f6c5552 --- /dev/null +++ b/src/port/atarist/audio.c @@ -0,0 +1,251 @@ +// Atari ST audio HAL: libxmp-lite Protracker decoder + 68k Timer A IRQ +// running PWM through YM2149 channel-A volume register. +// +// Pipeline: +// * halAudioInit creates an xmp_context, allocates a 2 KB mix buffer +// (in BSS), parks the Timer A vector at our 68k ISR (audio_isr.s), +// and starts Timer A at ~12.3 kHz (MFP prescaler 200, data 1). +// * halAudioPlayMod loads a .MOD via xmp_load_module_from_memory and +// calls xmp_start_player at the same 12288 Hz mono unsigned-8-bit +// output that the ISR consumes one byte at a time. +// * Each Timer A tick the ISR pops one byte off the buffer, writes +// its high 4 bits to YM register 8 (channel A volume), advances +// the play pointer, raises a refill flag at half/end. The refill +// itself happens in halAudioFrameTick on the main thread because +// libxmp-lite is not interrupt-safe. +// +// Quality budget: 4-bit unsigned PWM at 12.3 kHz on the YM is grim by +// modern standards but period-correct for stock 520ST/1040ST without +// the STe DMA chip. Library users that want the higher-fidelity STe +// path can skip this HAL and build their own. + +#include + +#include + +#include + +#include "hal.h" +#include "audioSfxMixInternal.h" +#include "joey/audio.h" + +// ----- Constants ----- + +// MFP Timer A registers and the autovector slot for its IRQ (vector +// $134, Setexc(0x134/4, ...) = Setexc(13, ...)). +#define ST_MFP_TACR ((volatile uint8_t *)0xFFFFFA19L) +#define ST_MFP_TADR ((volatile uint8_t *)0xFFFFFA1FL) +#define ST_MFP_IERA ((volatile uint8_t *)0xFFFFFA07L) +#define ST_MFP_IMRA ((volatile uint8_t *)0xFFFFFA13L) +#define ST_MFP_ISRA ((volatile uint8_t *)0xFFFFFA0FL) + +#define MFP_TA_BIT 0x20 +#define MFP_TACR_STOP 0x00 +#define MFP_TACR_DIV200 0x07 + +#define VEC_MFP_TA (0x134 / 4) +#define INT_TIMER_A 13 // Jenabint / Jdisint id for Timer A + +#define MIX_RATE 12288 // Hz, matches MFP 2.4576 MHz / 200 +#define MIX_BUFFER 2048 +#define MIX_HALF (MIX_BUFFER / 2) + +// ----- Module state ----- + +// The ISR (audio_isr.s) reads/writes these. Names must match the +// .extern declarations there. +volatile uint8_t gMixBuf[MIX_BUFFER]; +volatile uint8_t *gMixPos = NULL; +volatile uint8_t *gMixMid = NULL; +volatile uint8_t *gMixEnd = NULL; +volatile uint8_t gNeedRefill[2] = { 0, 0 }; + +extern void mfpTimerAIsr(void); + +static xmp_context gXmpCtx = NULL; +static bool gReady = false; +static bool gXmpStarted = false; +static bool gXmpLoaded = false; +static int gXmpLoopCount = 0; + +static void (*gOldTimerAVec)(void) = NULL; + +// SFX overlay shared with the DOS HAL (see src/core/audioSfxMix.c). +// Mixed in over libxmp's MOD output during halAudioFrameTick. +static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS]; + +// ----- Internal helpers ----- + +static long installTimerA(void) { + uint16_t i; + + // Park the buffer in known-silent state (unsigned 8-bit middle + // = 128) before opening the IRQ gate. + for (i = 0; i < MIX_BUFFER; i++) { + gMixBuf[i] = 0x80; + } + gMixPos = (volatile uint8_t *)gMixBuf; + gMixMid = (volatile uint8_t *)(gMixBuf + MIX_HALF); + gMixEnd = (volatile uint8_t *)(gMixBuf + MIX_BUFFER); + gNeedRefill[0] = 0; + gNeedRefill[1] = 0; + + // MFP Timer A: stop, install our vector, set prescaler 200 + data + // 1 (= 2.4576 MHz / 200 = 12288 Hz), then start. + *ST_MFP_TACR = MFP_TACR_STOP; + gOldTimerAVec = (void (*)(void))Setexc(VEC_MFP_TA, (long)mfpTimerAIsr); + *ST_MFP_TADR = 1; + *ST_MFP_TACR = MFP_TACR_DIV200; + Jenabint(INT_TIMER_A); + return 0; +} + + +static long uninstallTimerA(void) { + Jdisint(INT_TIMER_A); + *ST_MFP_TACR = MFP_TACR_STOP; + if (gOldTimerAVec != NULL) { + (void)Setexc(VEC_MFP_TA, (long)gOldTimerAVec); + gOldTimerAVec = NULL; + } + return 0; +} + + +static void silenceMixBuffer(void) { + uint16_t i; + for (i = 0; i < MIX_BUFFER; i++) { + gMixBuf[i] = 0x80; + } +} + + +// ----- HAL API (alphabetical) ----- + +bool halAudioInit(void) { + if (gReady) { + return true; + } + gXmpCtx = xmp_create_context(); + if (gXmpCtx == NULL) { + return false; + } + Supexec(installTimerA); + gReady = true; + return true; +} + + +void halAudioShutdown(void) { + if (!gReady) { + return; + } + Supexec(uninstallTimerA); + silenceMixBuffer(); + + if (gXmpCtx != NULL) { + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } + xmp_free_context(gXmpCtx); + gXmpCtx = NULL; + } + gReady = false; +} + + +bool halAudioIsPlayingMod(void) { + return gReady && gXmpStarted; +} + + +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + if (!gReady || gXmpCtx == NULL) { + return; + } + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } + if (xmp_load_module_from_memory(gXmpCtx, (void *)data, (long)length) != 0) { + return; + } + gXmpLoaded = true; + if (xmp_start_player(gXmpCtx, MIX_RATE, + XMP_FORMAT_8BIT | XMP_FORMAT_UNSIGNED | XMP_FORMAT_MONO) != 0) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + return; + } + gXmpLoopCount = loop ? -1 : 0; + gXmpStarted = true; +} + + +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + if (!gReady || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, MIX_RATE); +} + + +void halAudioStopMod(void) { + if (!gReady || gXmpCtx == NULL) { + return; + } + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } + silenceMixBuffer(); +} + + +void halAudioStopSfx(uint8_t slot) { + if (slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + gSfxSlots[slot].active = false; +} + + +// Drains ISR-raised refill flags by re-running libxmp's mixer into +// the consumed half then overlaying any active SFX. Called from the +// game loop, never from IRQ. +void halAudioFrameTick(void) { + if (!gReady) { + return; + } + if (gNeedRefill[0]) { + if (gXmpStarted) { + xmp_play_buffer(gXmpCtx, (void *)gMixBuf, MIX_HALF, gXmpLoopCount); + } else { + memset((void *)gMixBuf, 0x80, MIX_HALF); + } + audioSfxOverlayMix(gMixBuf, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); + gNeedRefill[0] = 0; + } + if (gNeedRefill[1]) { + if (gXmpStarted) { + xmp_play_buffer(gXmpCtx, (void *)(gMixBuf + MIX_HALF), MIX_HALF, gXmpLoopCount); + } else { + memset((void *)(gMixBuf + MIX_HALF), 0x80, MIX_HALF); + } + audioSfxOverlayMix(gMixBuf + MIX_HALF, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); + gNeedRefill[1] = 0; + } +} diff --git a/src/port/atarist/audio_isr.s b/src/port/atarist/audio_isr.s new file mode 100644 index 0000000..f1abc2c --- /dev/null +++ b/src/port/atarist/audio_isr.s @@ -0,0 +1,56 @@ +; Atari ST audio ISR -- MFP Timer A drives per-sample PWM output via +; the YM2149 channel-A volume register. +; +; Each interrupt: +; 1. Reads one byte from the libxmp-lite mix buffer. +; 2. Writes its high 4 bits to YM register 8 (channel A volume) so +; the analog filter on the YM output emits a 4-bit-resolution +; sample for that tick. +; 3. Advances the play pointer; raises a refill flag when crossing +; the midpoint or end of the buffer; wraps at the end. +; 4. Acks the MFP Timer-A bit in ISRA and RTEs. +; +; Communication with the C HAL is via four globals defined in audio.c: +; _gMixBuf -- start of the mix buffer (uint8_t array) +; _gMixPos -- volatile pointer to the next sample to play +; _gMixMid -- end of first half (used to detect mid-cross) +; _gMixEnd -- end of buffer (used to detect wrap) +; _gNeedRefill -- two-byte array; ISR writes 1 to mark a half stale + + xdef _mfpTimerAIsr + xref _gMixBuf + xref _gMixPos + xref _gMixMid + xref _gMixEnd + xref _gNeedRefill + +YM_SELECT equ $FFFF8800 +YM_DATA equ $FFFF8802 +MFP_ISRA equ $FFFFFA0F + +_mfpTimerAIsr: + movem.l d0/a0,-(sp) + + move.l _gMixPos,a0 + move.b (a0)+,d0 + lsr.b #4,d0 + and.w #$0F,d0 + + move.b #8,YM_SELECT + move.b d0,YM_DATA + + cmp.l _gMixMid,a0 + bne.s .check_end + move.b #1,_gNeedRefill+0 +.check_end: + cmp.l _gMixEnd,a0 + blt.s .save_pos + move.b #1,_gNeedRefill+1 + lea _gMixBuf,a0 +.save_pos: + move.l a0,_gMixPos + + move.b #$DF,MFP_ISRA + + movem.l (sp)+,d0/a0 + rte diff --git a/src/port/atarist/hal.c b/src/port/atarist/hal.c index 1fe88e1..f9d0526 100644 --- a/src/port/atarist/hal.c +++ b/src/port/atarist/hal.c @@ -64,8 +64,8 @@ // ----- Prototypes ----- static uint16_t quantizeColorToSt(uint16_t orgb); -static void c2pRow(const uint8_t *src, uint16_t *dst); -static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1); +static void c2pRow(const uint8_t *src, uint16_t *dst, uint16_t groupStart, uint16_t groupEnd); +static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1, uint16_t groupStart, uint16_t groupEnd); static void flattenScbPalettes(const SurfaceT *src); static void writeDiagnostics(void); static long writePrevPaletteRegs(void); @@ -131,9 +131,10 @@ static bool gCacheValid = false; // ----- Internal helpers (alphabetical) ----- -// Convert 16 chunky pixels (8 bytes 4bpp packed) to 4 ST planar words. -// The output word order is plane 0, 1, 2, 3 (low bit = plane 0). -static void c2pRow(const uint8_t *src, uint16_t *dst) { +// Convert 16 chunky pixels (8 bytes 4bpp packed) to 4 ST planar words +// per group. groupStart..groupEnd selects a horizontal sub-range so +// halPresentRect can avoid touching unchanged groups. +static void c2pRow(const uint8_t *src, uint16_t *dst, uint16_t groupStart, uint16_t groupEnd) { uint16_t group; uint16_t px; uint16_t plane0; @@ -144,7 +145,7 @@ static void c2pRow(const uint8_t *src, uint16_t *dst) { uint8_t nibble; uint16_t bit; - for (group = 0; group < ST_GROUPS_PER_ROW; group++) { + for (group = groupStart; group < groupEnd; group++) { plane0 = 0; plane1 = 0; plane2 = 0; @@ -168,7 +169,7 @@ static void c2pRow(const uint8_t *src, uint16_t *dst) { } -static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1) { +static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1, uint16_t groupStart, uint16_t groupEnd) { int16_t y; const uint8_t *srcLine; uint16_t *dstLine; @@ -176,7 +177,7 @@ static void c2pRange(const SurfaceT *src, int16_t y0, int16_t y1) { for (y = y0; y < y1; y++) { srcLine = &src->pixels[y * SURFACE_BYTES_PER_ROW]; dstLine = (uint16_t *)&gScreenBase[y * ST_BYTES_PER_ROW]; - c2pRow(srcLine, dstLine); + c2pRow(srcLine, dstLine, groupStart, groupEnd); } } @@ -481,19 +482,27 @@ void halPresent(const SurfaceT *src) { return; } refreshPaletteStateIfNeeded(src); - c2pRange(src, 0, SURFACE_HEIGHT); + c2pRange(src, 0, SURFACE_HEIGHT, 0, ST_GROUPS_PER_ROW); } void halPresentRect(const SurfaceT *src, int16_t x, int16_t y, uint16_t w, uint16_t h) { - (void)x; - (void)w; + uint16_t groupStart; + uint16_t groupEnd; if (src == NULL || !gModeSet) { return; } refreshPaletteStateIfNeeded(src); - c2pRange(src, y, y + (int16_t)h); + // Each c2p group covers 16 horizontal pixels. Round dirty pixel + // range to the enclosing group range to keep the planar word + // alignment without missing edge pixels. + groupStart = (uint16_t)(x >> 4); + groupEnd = (uint16_t)(((uint16_t)x + w + 15) >> 4); + if (groupEnd > ST_GROUPS_PER_ROW) { + groupEnd = ST_GROUPS_PER_ROW; + } + c2pRange(src, y, y + (int16_t)h, groupStart, groupEnd); } diff --git a/src/port/dos/audio.c b/src/port/dos/audio.c new file mode 100644 index 0000000..271ed52 --- /dev/null +++ b/src/port/dos/audio.c @@ -0,0 +1,633 @@ +// DOS audio HAL: Sound Blaster Pro / SB16 8-bit mono playback driven +// by 8237 auto-init DMA, fed by libxmp-lite's mixer. +// +// Pipeline: +// * halAudioInit detects the card via DSP reset + version, allocates +// a 4 KB conventional-memory DMA buffer (aligned to not cross a +// 64 KB boundary), installs an IRQ handler on the SB's IRQ line, +// creates an xmp_context, and arms auto-init DMA at 22050 Hz mono +// 8-bit. The DMA buffer is split into two halves; the SB IRQ fires +// at the end of each half and our handler refills the *other* half +// by calling xmp_play_buffer with libxmp-lite's mixer output. +// * halAudioPlayMod loads a .MOD blob through xmp_load_module and +// starts the player; subsequent IRQs auto-pump the audio. +// * halAudioPlaySfx routes through libxmp-lite's SMIX overlay so SFX +// mix on top of the music using xmp_smix_play_sample. +// +// Defaults match DOSBox-Staging's emulated SB Pro: base $220, IRQ 7, +// 8-bit DMA channel 1. BLASTER environment variable overrides each. +// +// Because DJGPP runs in protected mode with paging, the IRQ handler +// code and any data it touches (DMA selector, libxmp context pointer, +// the DMA buffer) must be locked or a page fault at interrupt time +// will hang the machine. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "hal.h" +#include "audioSfxMixInternal.h" +#include "joey/audio.h" + +// ----- SB I/O port offsets from base ----- + +#define SB_RESET_OFFSET 0x06 +#define SB_READ_OFFSET 0x0A +#define SB_WRITE_OFFSET 0x0C +#define SB_DATA_AVAIL_OFFSET 0x0E + +// ----- DSP commands ----- + +#define DSP_GET_VERSION 0xE1 +#define DSP_SPEAKER_ON 0xD1 +#define DSP_SPEAKER_OFF 0xD3 +#define DSP_TIME_CONST 0x40 +#define DSP_BLOCK_SIZE 0x48 +#define DSP_OUT_8_AUTO 0x1C +#define DSP_HALT_DMA8 0xD0 +#define DSP_EXIT_AUTO 0xDA + +#define DSP_RESET_RETRIES 1024 +#define DSP_READY_BYTE 0xAA + +// ----- 8237 DMA controller (channel 1) ----- + +#define DMA_MASK 0x0A +#define DMA_MODE 0x0B +#define DMA_FLIPFLOP 0x0C +#define DMA1_ADDR 0x02 +#define DMA1_COUNT 0x03 +#define DMA1_PAGE 0x83 + +// Channel-1 mode byte: single transfer (01), incrementing (0), +// auto-init (1), read transfer (10), channel 1 (01) = 01011001 = 0x59. +#define DMA_MODE_OUT_AI_CH1 0x59 +#define DMA_MASK_DISABLE_CH1 0x05 // bit 2 = mask, channel 1 +#define DMA_MASK_ENABLE_CH1 0x01 + +// ----- PIC ----- + +#define PIC1_CMD 0x20 +#define PIC1_IMR 0x21 +#define PIC2_CMD 0xA0 +#define PIC2_IMR 0xA1 +#define PIC_EOI 0x20 +#define PIC_CASCADE_IRQ 2 + +// ----- Defaults ----- + +#define DEFAULT_SB_BASE 0x220 +#define DEFAULT_SB_IRQ 7 +#define DEFAULT_SB_DMA8 1 + +// ----- Mix configuration ----- + +#define MIX_RATE 22050 +#define MIX_DMA_BUFFER 4096 // total; two 2048-byte halves +#define MIX_HALF (MIX_DMA_BUFFER / 2) + +#define ISR_LOCK_SIZE 4096 + +// ----- Module state ----- + +static uint16_t gSbBase = DEFAULT_SB_BASE; +static uint8_t gSbIrq = DEFAULT_SB_IRQ; +static uint8_t gSbDma8 = DEFAULT_SB_DMA8; +static uint8_t gDspMajor = 0; +static uint8_t gDspMinor = 0; +static bool gDspPresent = false; + +// Conventional-memory DMA buffer. +static int gDmaSelector = 0; +static uint32_t gDmaLinear = 0; +static uint8_t *gDmaBuf = NULL; + +// PIC vector for the SB IRQ. +static _go32_dpmi_seginfo gOldIrqVec; +static _go32_dpmi_seginfo gNewIrqVec; +static bool gIrqHooked = false; + +// libxmp state. +static xmp_context gXmpCtx = NULL; +static bool gXmpStarted = false; +static bool gXmpLoaded = false; +static volatile int gXmpLoopCount = 0; // last arg to xmp_play_buffer + +// SFX overlay: libxmp-lite's SMIX takes a file path (not an in-memory +// buffer), so we run a one-shot sample mixer (shared with the ST HAL, +// see src/core/audioSfxMix.c) on top of the MOD output. Mix happens in +// refillHalf after libxmp has filled the half-buffer. +static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS]; + +// ISR-side state. volatile so the main loop sees IRQ updates promptly. +// gNextHalf flips between 0 and 1 each IRQ; gNeedRefill[i] is raised +// in the IRQ handler and lowered in halAudioFrameTick after the +// half has been refilled. We deliberately do NOT call libxmp-lite +// from interrupt context -- the mixer allocates internally and is +// not reentrancy-safe -- so all work that touches the xmp context +// happens in the main thread. +static volatile uint8_t gNextHalf = 0; +static volatile bool gNeedRefill[2] = { false, false }; + +// ----- Prototypes ----- + +static void parseBlasterEnv(void); +static bool dspReset(void); +static void dspWrite(uint8_t value); +static int dspRead(void); +static int picVectorForIrq(uint8_t irq); +static bool allocDmaBuffer(void); +static void freeDmaBuffer(void); +static void programDmaController(void); +static void programDspAutoInit(void); +static void haltDspDma(void); +static void picUnmaskIrq(uint8_t irq); +static void picMaskIrq(uint8_t irq); +static void sbIsr(void); +static void refillHalf(uint8_t halfIdx); + +// ----- Internal helpers ----- + +static void parseBlasterEnv(void) { + const char *blaster; + const char *p; + char ch; + long val; + char *end; + + blaster = getenv("BLASTER"); + if (blaster == NULL || *blaster == '\0') { + return; + } + for (p = blaster; *p != '\0';) { + while (*p != '\0' && isspace((unsigned char)*p)) { + p++; + } + if (*p == '\0') { + break; + } + ch = (char)toupper((unsigned char)*p); + p++; + val = strtol(p, &end, ch == 'A' ? 16 : 10); + if (end == p) { + while (*p != '\0' && !isspace((unsigned char)*p)) { + p++; + } + continue; + } + p = end; + switch (ch) { + case 'A': gSbBase = (uint16_t)val; break; + case 'I': gSbIrq = (uint8_t)val; break; + case 'D': gSbDma8 = (uint8_t)val; break; + default: break; + } + } +} + + +static void dspWrite(uint8_t value) { + int i; + + for (i = 0; i < 65536; i++) { + if ((inportb(gSbBase + SB_WRITE_OFFSET) & 0x80) == 0) { + break; + } + } + outportb(gSbBase + SB_WRITE_OFFSET, value); +} + + +static int dspRead(void) { + int i; + + for (i = 0; i < 65536; i++) { + if ((inportb(gSbBase + SB_DATA_AVAIL_OFFSET) & 0x80) != 0) { + return inportb(gSbBase + SB_READ_OFFSET); + } + } + return -1; +} + + +static bool dspReset(void) { + int retry; + int byte; + uint16_t port; + int i; + + port = (uint16_t)(gSbBase + SB_RESET_OFFSET); + outportb(port, 1); + for (i = 0; i < 8; i++) { + (void)inportb(port); + } + outportb(port, 0); + + for (retry = 0; retry < DSP_RESET_RETRIES; retry++) { + byte = dspRead(); + if (byte == DSP_READY_BYTE) { + return true; + } + } + return false; +} + + +// PIC1 (master) handles IRQ 0-7 at INT $08-$0F. PIC2 (slave) handles +// IRQ 8-15 at INT $70-$77. Map an IRQ number to its PM interrupt +// vector. SB's typical IRQs are 5 or 7 (PIC1) or 10 (PIC2). +static int picVectorForIrq(uint8_t irq) { + if (irq < 8) { + return 0x08 + irq; + } + return 0x70 + (irq - 8); +} + + +// Allocate a DMA buffer in conventional memory that does not cross a +// 64 KB physical-page boundary -- the 8237 cannot DMA across that +// boundary on 8-bit channels. We over-allocate to 2x size and use the +// lower-aligned half so we always have room. +static bool allocDmaBuffer(void) { + int sel; + int seg; + uint32_t linear; + uint32_t aligned; + int paragraphs; + + paragraphs = ((MIX_DMA_BUFFER * 2) + 15) >> 4; + seg = __dpmi_allocate_dos_memory(paragraphs, &sel); + if (seg < 0) { + return false; + } + linear = (uint32_t)seg << 4; + + // Round linear up to a starting offset that gives us + // MIX_DMA_BUFFER without crossing the next 64 KB boundary. + if (((linear & 0xFFFF) + MIX_DMA_BUFFER) > 0x10000) { + aligned = (linear + 0xFFFFu) & ~0xFFFFu; + } else { + aligned = linear; + } + + gDmaSelector = sel; + gDmaLinear = aligned; + gDmaBuf = (uint8_t *)(__djgpp_conventional_base + aligned); + memset(gDmaBuf, 0x80, MIX_DMA_BUFFER); // 8-bit unsigned silence + return true; +} + + +static void freeDmaBuffer(void) { + if (gDmaSelector != 0) { + __dpmi_free_dos_memory(gDmaSelector); + gDmaSelector = 0; + gDmaLinear = 0; + gDmaBuf = NULL; + } +} + + +static void programDmaController(void) { + uint8_t page; + uint16_t offset; + uint16_t count; + + page = (uint8_t)((gDmaLinear >> 16) & 0xFF); + offset = (uint16_t)(gDmaLinear & 0xFFFF); + count = (uint16_t)(MIX_DMA_BUFFER - 1); + + outportb(DMA_MASK, DMA_MASK_DISABLE_CH1); + outportb(DMA_FLIPFLOP, 0); + outportb(DMA_MODE, DMA_MODE_OUT_AI_CH1); + outportb(DMA1_ADDR, (uint8_t)(offset & 0xFF)); + outportb(DMA1_ADDR, (uint8_t)((offset >> 8) & 0xFF)); + outportb(DMA1_PAGE, page); + outportb(DMA1_COUNT, (uint8_t)(count & 0xFF)); + outportb(DMA1_COUNT, (uint8_t)((count >> 8) & 0xFF)); + outportb(DMA_MASK, DMA_MASK_ENABLE_CH1); +} + + +// 8-bit DSP time constant: tc = 256 - 1000000 / rate. SB plays at +// 1000000 / (256 - tc) Hz, so we round to the nearest representable +// rate. For 22050 Hz: tc = 256 - 45 = 211. +static uint8_t dspTimeConst(uint16_t rate) { + uint32_t tc; + + tc = 256u - (1000000u / rate); + if (tc < 4) { + tc = 4; + } + if (tc > 255) { + tc = 255; + } + return (uint8_t)tc; +} + + +static void programDspAutoInit(void) { + uint16_t blockMinusOne; + + dspWrite(DSP_SPEAKER_ON); + dspWrite(DSP_TIME_CONST); + dspWrite(dspTimeConst(MIX_RATE)); + + blockMinusOne = (uint16_t)(MIX_HALF - 1); + dspWrite(DSP_BLOCK_SIZE); + dspWrite((uint8_t)(blockMinusOne & 0xFF)); + dspWrite((uint8_t)((blockMinusOne >> 8) & 0xFF)); + + dspWrite(DSP_OUT_8_AUTO); +} + + +static void haltDspDma(void) { + dspWrite(DSP_HALT_DMA8); + dspWrite(DSP_EXIT_AUTO); + dspWrite(DSP_SPEAKER_OFF); +} + + +// Unmask the IRQ line in the PIC's Interrupt Mask Register so the IRQ +// can reach the CPU. Some environments (DOSBox in particular) leave the +// SB IRQ masked at boot; without this the hooked vector never fires +// and audio drops out after the first half-buffer. +static void picUnmaskIrq(uint8_t irq) { + uint16_t port; + uint8_t bit; + uint8_t mask; + + if (irq < 8) { + port = PIC1_IMR; + bit = (uint8_t)(1u << irq); + } else { + port = PIC2_IMR; + bit = (uint8_t)(1u << (irq - 8)); + } + mask = inportb(port); + mask = (uint8_t)(mask & ~bit); + outportb(port, mask); + if (irq >= 8) { + // Cascade line on PIC1 must also be unmasked for PIC2 to reach + // the CPU. + mask = inportb(PIC1_IMR); + mask = (uint8_t)(mask & ~(1u << PIC_CASCADE_IRQ)); + outportb(PIC1_IMR, mask); + } +} + + +static void picMaskIrq(uint8_t irq) { + uint16_t port; + uint8_t bit; + uint8_t mask; + + if (irq < 8) { + port = PIC1_IMR; + bit = (uint8_t)(1u << irq); + } else { + port = PIC2_IMR; + bit = (uint8_t)(1u << (irq - 8)); + } + mask = inportb(port); + mask = (uint8_t)(mask | bit); + outportb(port, mask); +} + + +// Refill one half of the DMA buffer from libxmp-lite's mixer. +// Called from halAudioFrameTick on the main thread (NOT in IRQ +// context); the IRQ only flags which halves need refilling. +// xmp_play_buffer fills our bytes regardless of whether a module is +// loaded -- it returns silence (centered 0x80) when idle. +static void refillHalf(uint8_t halfIdx) { + uint8_t *dst; + + dst = gDmaBuf + ((uint32_t)halfIdx * MIX_HALF); + if (gXmpCtx != NULL && gXmpStarted) { + // libxmp emits unsigned 8-bit when XMP_FORMAT_UNSIGNED is set + // in xmp_start_player, matching SB's DMA format -- no extra + // signed/unsigned conversion needed here. + xmp_play_buffer(gXmpCtx, dst, MIX_HALF, gXmpLoopCount); + } else { + memset(dst, 0x80, MIX_HALF); + } + audioSfxOverlayMix(dst, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); +} + + +// SB IRQ. Acks the SB (read $0E), marks the half that just played +// for refill, EOIs the PIC. The actual mixer work happens in +// halAudioFrameTick on the main thread. +static void sbIsr(void) { + uint8_t fillHalf; + + fillHalf = gNextHalf; + gNextHalf = (uint8_t)(gNextHalf ^ 1); + gNeedRefill[fillHalf] = true; + + (void)inportb(gSbBase + SB_DATA_AVAIL_OFFSET); + + if (gSbIrq >= 8) { + outportb(PIC2_CMD, PIC_EOI); + } + outportb(PIC1_CMD, PIC_EOI); +} + + +// ----- HAL API (alphabetical) ----- + +bool halAudioInit(void) { + int major; + int minor; + int vector; + + if (gDspPresent) { + return true; + } + parseBlasterEnv(); + if (!dspReset()) { + return false; + } + dspWrite(DSP_GET_VERSION); + major = dspRead(); + minor = dspRead(); + if (major < 0 || minor < 0) { + return false; + } + gDspMajor = (uint8_t)major; + gDspMinor = (uint8_t)minor; + + if (!allocDmaBuffer()) { + return false; + } + + gXmpCtx = xmp_create_context(); + if (gXmpCtx == NULL) { + freeDmaBuffer(); + return false; + } + + // Lock ISR code + the data sbIsr touches at IRQ time. The mixer + // refill no longer runs in interrupt context, so refillHalf and + // libxmp internals do not need locking. + _go32_dpmi_lock_code(sbIsr, ISR_LOCK_SIZE); + _go32_dpmi_lock_data((void *)&gNextHalf, sizeof(gNextHalf)); + _go32_dpmi_lock_data((void *)gNeedRefill, sizeof(gNeedRefill)); + _go32_dpmi_lock_data((void *)&gSbBase, sizeof(gSbBase)); + _go32_dpmi_lock_data((void *)&gSbIrq, sizeof(gSbIrq)); + + vector = picVectorForIrq(gSbIrq); + _go32_dpmi_get_protected_mode_interrupt_vector(vector, &gOldIrqVec); + gNewIrqVec.pm_offset = (unsigned long)sbIsr; + gNewIrqVec.pm_selector = _go32_my_cs(); + if (_go32_dpmi_allocate_iret_wrapper(&gNewIrqVec) != 0) { + xmp_free_context(gXmpCtx); + gXmpCtx = NULL; + freeDmaBuffer(); + return false; + } + _go32_dpmi_set_protected_mode_interrupt_vector(vector, &gNewIrqVec); + gIrqHooked = true; + + gNextHalf = 0; + programDmaController(); + programDspAutoInit(); + picUnmaskIrq(gSbIrq); + + gDspPresent = true; + return true; +} + + +void halAudioShutdown(void) { + int vector; + + if (!gDspPresent) { + return; + } + haltDspDma(); + if (gIrqHooked) { + picMaskIrq(gSbIrq); + vector = picVectorForIrq(gSbIrq); + _go32_dpmi_set_protected_mode_interrupt_vector(vector, &gOldIrqVec); + _go32_dpmi_free_iret_wrapper(&gNewIrqVec); + gIrqHooked = false; + } + if (gXmpCtx != NULL) { + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } + xmp_free_context(gXmpCtx); + gXmpCtx = NULL; + } + freeDmaBuffer(); + (void)dspReset(); + gDspPresent = false; +} + + +bool halAudioIsPlayingMod(void) { + return gXmpStarted; +} + + +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + if (!gDspPresent || gXmpCtx == NULL) { + return; + } + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } + if (xmp_load_module_from_memory(gXmpCtx, (void *)data, (long)length) != 0) { + return; + } + gXmpLoaded = true; + if (xmp_start_player(gXmpCtx, MIX_RATE, + XMP_FORMAT_8BIT | XMP_FORMAT_UNSIGNED | XMP_FORMAT_MONO) != 0) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + return; + } + // libxmp-lite controls looping per-buffer-call via the last arg of + // xmp_play_buffer (0 = stop on song end, nonzero = max loop count + // before stop, -1 = forever). We honor `loop` by feeding that + // value through gXmpLoopCount; the IRQ hands it to play_buffer. + gXmpLoopCount = loop ? -1 : 0; + gXmpStarted = true; +} + + +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + if (!gDspPresent || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, MIX_RATE); +} + + +void halAudioStopMod(void) { + if (!gDspPresent || gXmpCtx == NULL) { + return; + } + if (gXmpStarted) { + xmp_end_player(gXmpCtx); + gXmpStarted = false; + } + if (gXmpLoaded) { + xmp_release_module(gXmpCtx); + gXmpLoaded = false; + } +} + + +void halAudioStopSfx(uint8_t slot) { + if (slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + gSfxSlots[slot].active = false; +} + + +// Called once per game-loop iteration from the application. Drains +// the IRQ-raised refill flags and refills out-of-IRQ (so libxmp-lite +// is never re-entered from interrupt context). At 70 Hz polling a +// 22050 Hz half-buffer of 2048 samples drains ~143000 samples/sec +// of headroom, well above the playback rate, so a missed frame or +// two is OK without underflow. +void halAudioFrameTick(void) { + int i; + + if (!gDspPresent) { + return; + } + for (i = 0; i < 2; i++) { + if (gNeedRefill[i]) { + refillHalf((uint8_t)i); + gNeedRefill[i] = false; + } + } +} diff --git a/src/port/iigs/audio.c b/src/port/iigs/audio.c new file mode 100644 index 0000000..d833d84 --- /dev/null +++ b/src/port/iigs/audio.c @@ -0,0 +1,70 @@ +// Apple IIgs audio HAL stub. Real implementation pending. +// +// Pipeline already in place: +// * toolchains/install.sh fetches Ninjaforce NTP source into +// toolchains/iigs/ntp/ (CRLF stripped on extraction). +// * make/iigs.mk assembles ninjatrackerplus.s with Merlin32 into +// build/iigs/audio/ntpplayer.bin (34 KB raw 65816, originally +// org $0F0000 but bank-internal so it relocates at any bank-start +// load address). +// * package-disk.sh bundles ntpplayer.bin onto the IIgs disk image +// alongside the demo binaries. +// +// Why this file is still a stub: +// The runtime load path (NewHandle + fopen + fread on NTPPLAYER.BIN) +// pulls Memory Manager + ORCA stdio into the link, and ORCA Linker +// fails with "Expression too complex in 13/SysLib" when those are +// added on top of the existing graphics + input HAL plumbing for +// *every* demo (the IIgs build links each binary as one monolithic +// image, no static-library culling). Bringing in those tool sets +// needs to land alongside the JSL trampoline that actually uses the +// loaded NTP -- one combined effort, with the demos that don't need +// audio either kept on a thinner audio shim or split out so the +// linker isn't asked to resolve everything for everyone. +// +// Until that combined load + trampoline iteration ships, every entry +// here is a safe no-op so the audio API stays callable. + +#include "hal.h" + + +bool halAudioInit(void) { + return false; +} + + +void halAudioShutdown(void) { +} + + +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + (void)data; + (void)length; + (void)loop; +} + + +void halAudioStopMod(void) { +} + + +bool halAudioIsPlayingMod(void) { + return false; +} + + +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + (void)slot; + (void)sample; + (void)length; + (void)rateHz; +} + + +void halAudioStopSfx(uint8_t slot) { + (void)slot; +} + + +void halAudioFrameTick(void) { +} diff --git a/src/port/iigs/audio_full.c b/src/port/iigs/audio_full.c new file mode 100644 index 0000000..f8bdf82 --- /dev/null +++ b/src/port/iigs/audio_full.c @@ -0,0 +1,394 @@ +// Apple IIgs audio HAL -- full version (linked only into the AUDIO +// demo via make/iigs.mk's split source set). audio.c keeps the no-op +// stub for every other demo so the monolithic IIgs link budget stays +// safe. +// +// Stage 1 (this file's first cut): load ntpplayer.bin (the Merlin32- +// assembled NinjaTrackerPlus replayer staged by the iigs.mk Merlin +// rule and bundled on the disk image) into a Memory Manager handle. +// halAudioInit reports true if the load succeeds. PlayMod / PlaySfx / +// StopMod still no-op until the JSL trampoline lands -- that's stage +// 2 and gets its own file once the inline-asm syntax is nailed down. + +#include +#include + +#include +#include + +#include "hal.h" +#include "joey/audio.h" + +// ----- Constants ----- + +#define NTP_FILENAME "NTPPLAYER.BIN" +#define NTP_BUFFER_BYTES (64L * 1024L) +// Sanity check: NTP source assembles to ~34 KB; reject reads that +// come back too short to plausibly be the replayer. +#define NTP_MIN_BYTES 32000L + +// Ensoniq 5503 (DOC) I/O at $E1C03C..$E1C03E. NTP saves its preferred +// sound_control byte at $E100CA so callers that want DOC-register +// access don't have to know NTP's mode bits. +#define DOC_SOUND_CONTROL ((volatile uint8_t *)0xE1C03CL) +#define DOC_SOUND_DATA ((volatile uint8_t *)0xE1C03DL) +#define DOC_SOUND_ADDRESS ((volatile uint8_t *)0xE1C03EL) +#define DOC_IRQ_VOLUME ((volatile uint8_t *)0xE100CAL) + +// DOC oscillator control register layout: bit 0 = halt. NTPstreamsound +// itself uses 0x03 (halt + IE) when it wants an osc stopped, so we +// mirror that. +#define DOC_OSC_CTRL_BASE 0xA0 +#define DOC_OSC_HALT_BITS 0x03 + +// SFX: one fixed-bank handle holding all slots' stream structures + +// sample buffers. Per slot: 16-byte struct followed by 4080 bytes of +// sample, total 4 KB. JOEY_AUDIO_SFX_SLOTS slots fit in 16 KB. +#define SFX_SLOT_BYTES (4L * 1024L) +#define SFX_BUFFER_BYTES (SFX_SLOT_BYTES * (long)JOEY_AUDIO_SFX_SLOTS) + +// NTP uses oscillators 0..N for music. Each SFX slot reserves 2 oscs +// (1 streamer + 1 player) starting at 14: slot 0 = 14..15, slot 1 = +// 16..17, etc. With 7 max music tracks * 2 oscs = 14 (oscs 0..13) +// and NTP's interrupt osc at 31, this avoids both ranges. Each slot +// also gets its own 512-byte-aligned DOC RAM page so they don't share +// the streaming buffer. +#define SFX_BASE_OSC 14 +#define SFX_OSCS_PER_SLOT 2 +#define SFX_BASE_DOC_PAGE 0xC0 // 512-aligned (bit 0 must be 0) +#define SFX_DOC_PAGE_STEP 2 // 512 bytes per slot +#define SFX_VOLUME 255 +#define SFX_CHANNEL_LEFT 0x00 // Upper 4 bits = output channel + +// DOC freq formula: rate_Hz = freq * input_clk / (8 * num_oscs * 65536). +// NTP's max_tracks_available = 31 + 1 interrupt osc => 32 active oscs. +// IIgs DOC input clock = 7158000 Hz; (input_clk / 8 / 32) = 27961. +// Inverted: freq_word = rate * 65536 / 27961. Caps usable rate at +// ~27 kHz (above that freq_word overflows uint16). +#define IIGS_DOC_DIVISOR 27961UL +#define IIGS_MAX_SFX_RATE 27000U + +// ----- Module state ----- + +static Handle gNTPHandle = NULL; +static uint32_t gNTPBase = 0; +static Handle gModuleHandle = NULL; +static uint32_t gModuleBase = 0; +static Handle gSfxHandle = NULL; +static uint32_t gSfxBase = 0; +static bool gNTPReady = false; +static bool gNTPPlaying = false; + +// SFX handle layout: stream structure first, sample bytes after. +// Both end up at known 24-bit addresses, side-stepping the small +// memory model's 16-bit pointer issue. +// +// Stream structure consumed by NTPstreamsound (per NTP docs): +// 0..3 : sample pointer (4 bytes, little-endian, 24-bit in low 3) +// 4..7 : sample length (4 bytes) +// 8..9 : freq word (DOC frequency, 2 bytes) +// 10 : DOC RAM page (512-aligned) +// 11 : first oscillator (0..29) +// 12 : playing-osc count (1..30; total oscs used = 1 + count) +// 13 : volume of playing osc 0 (0..255) +// 14 : channel of playing osc 0 (upper 4 bits) +#define SFX_STRUCT_BYTES 16 +#define SFX_SAMPLE_OFFSET SFX_STRUCT_BYTES + +// Self-modifying call stub. Bakes the X/Y/A register loads AND the +// JSL target into the buffer, so the C-side inline asm only needs +// `jsl gCallStub` -- no global-operand references in the asm block. +// +// (We tried the obvious `lda gAsmGlobal / jsl gJslStub` shape first; +// ORCA's inline assembler accepts the first absolute-global operand +// in a block but rejects the second and third with "invalid operand". +// Folding the loads into the stub side-steps the issue entirely.) +// +// Layout (15 bytes): +// 00: C2 30 REP #$30 ; 16-bit M/X +// 02: A2 lo hi LDX #X +// 05: A0 lo hi LDY #Y +// 08: A9 lo hi LDA #A +// 0B: 22 lo hi bk JSL target +// 0F: 6B RTL +static unsigned char gCallStub[16]; + + +// ----- Internal helpers ----- + +static void buildCallStub(uint32_t target, uint16_t x, uint16_t y, uint16_t a) { + gCallStub[0] = 0xC2; + gCallStub[1] = 0x30; + gCallStub[2] = 0xA2; + gCallStub[3] = (unsigned char)(x & 0xFFu); + gCallStub[4] = (unsigned char)((x >> 8) & 0xFFu); + gCallStub[5] = 0xA0; + gCallStub[6] = (unsigned char)(y & 0xFFu); + gCallStub[7] = (unsigned char)((y >> 8) & 0xFFu); + gCallStub[8] = 0xA9; + gCallStub[9] = (unsigned char)(a & 0xFFu); + gCallStub[10] = (unsigned char)((a >> 8) & 0xFFu); + gCallStub[11] = 0x22; + gCallStub[12] = (unsigned char)(target & 0xFFu); + gCallStub[13] = (unsigned char)((target >> 8) & 0xFFu); + gCallStub[14] = (unsigned char)((target >> 16) & 0xFFu); + gCallStub[15] = 0x6B; +} + +static bool loadNTP(void) { + Handle h; + Pointer p; + FILE *fp; + size_t bytesRead; + + h = NewHandle(NTP_BUFFER_BYTES, _ownerid, + attrFixed | attrLocked | attrPage | attrNoCross, + NULL); + if (h == NULL || _toolErr != 0) { + return false; + } + HLock(h); + p = *h; + if (p == NULL) { + DisposeHandle(h); + return false; + } + + fp = fopen(NTP_FILENAME, "rb"); + if (fp == NULL) { + DisposeHandle(h); + return false; + } + bytesRead = fread(p, 1, NTP_BUFFER_BYTES, fp); + fclose(fp); + if (bytesRead < NTP_MIN_BYTES) { + DisposeHandle(h); + return false; + } + + gNTPHandle = h; + gNTPBase = (uint32_t)p; + return true; +} + + +// ----- HAL API (alphabetical) ----- + +bool halAudioInit(void) { + Handle modHandle; + Handle sfxHandle; + + if (gNTPReady) { + return true; + } + if (!loadNTP()) { + return false; + } + // A second fixed-bank handle holds the per-call .NTP module bytes. + // BlockMove copies the caller's data here so we have a known 24-bit + // address to pass to NTPprepare in X/Y. + modHandle = NewHandle(NTP_BUFFER_BYTES, _ownerid, + attrFixed | attrLocked | attrPage | attrNoCross, + NULL); + if (modHandle == NULL || _toolErr != 0) { + DisposeHandle(gNTPHandle); + gNTPHandle = NULL; + return false; + } + HLock(modHandle); + gModuleHandle = modHandle; + gModuleBase = (uint32_t)*modHandle; + + // Third fixed-bank handle for SFX sample storage. Same idea as + // the module handle: NTPstreamsound wants a 24-bit pointer, and + // the small-memory-model caller can only hand us a 16-bit one. + sfxHandle = NewHandle(SFX_BUFFER_BYTES, _ownerid, + attrFixed | attrLocked | attrPage | attrNoCross, + NULL); + if (sfxHandle == NULL || _toolErr != 0) { + DisposeHandle(gModuleHandle); + DisposeHandle(gNTPHandle); + gModuleHandle = NULL; + gNTPHandle = NULL; + return false; + } + HLock(sfxHandle); + gSfxHandle = sfxHandle; + gSfxBase = (uint32_t)*sfxHandle; + + gNTPReady = true; + return true; +} + + +void halAudioShutdown(void) { + if (!gNTPReady) { + return; + } + if (gNTPPlaying) { + halAudioStopMod(); + } + if (gSfxHandle != NULL) { + DisposeHandle(gSfxHandle); + gSfxHandle = NULL; + gSfxBase = 0; + } + if (gModuleHandle != NULL) { + DisposeHandle(gModuleHandle); + gModuleHandle = NULL; + gModuleBase = 0; + } + if (gNTPHandle != NULL) { + DisposeHandle(gNTPHandle); + gNTPHandle = NULL; + gNTPBase = 0; + } + gNTPReady = false; +} + + +bool halAudioIsPlayingMod(void) { + return gNTPReady && gNTPPlaying; +} + + +void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { + if (!gNTPReady || gModuleHandle == NULL) { + return; + } + if (length == 0 || length > NTP_BUFFER_BYTES) { + return; + } + // Drop any previous song before loading a new one. + if (gNTPPlaying) { + halAudioStopMod(); + } + BlockMove((Pointer)data, (Pointer)gModuleBase, length); + + // NTPprepare(modPtr in X/Y, doubling in A). modPtr is a 24-bit + // address: low 16 bits in X, bank byte in Y (high byte is don't + // care, low byte is the bank). + buildCallStub(gNTPBase + 0, + (uint16_t)(gModuleBase & 0xFFFFu), + (uint16_t)((gModuleBase >> 16) & 0xFFu), + 0); + asm { + jsl gCallStub + } + + // NTPplay(loopFlag in A). 0 = loop forever, 1 = play once. + buildCallStub(gNTPBase + 3, 0, 0, loop ? 0 : 1); + asm { + jsl gCallStub + } + gNTPPlaying = true; +} + + +void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { + unsigned char *sfx; + uint32_t slotBase; + uint32_t sampleAddr; + uint32_t structAddr; + uint16_t freqWord; + uint16_t clampedRate; + + if (!gNTPReady || gSfxHandle == NULL || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + if (length == 0 || length > (SFX_SLOT_BYTES - SFX_SAMPLE_OFFSET)) { + return; + } + + clampedRate = (rateHz > IIGS_MAX_SFX_RATE) ? IIGS_MAX_SFX_RATE : rateHz; + freqWord = (uint16_t)(((uint32_t)clampedRate * 65536UL) / IIGS_DOC_DIVISOR); + + slotBase = gSfxBase + (uint32_t)slot * (uint32_t)SFX_SLOT_BYTES; + structAddr = slotBase; + sampleAddr = slotBase + SFX_SAMPLE_OFFSET; + + // Copy the sample into this slot's fixed-bank region, converting + // signed 8-bit (public API contract) to unsigned 8-bit (DOC RAM + // format) by flipping the sign bit. + { + unsigned char *dst; + uint32_t i; + + dst = (unsigned char *)sampleAddr; + for (i = 0; i < length; i++) { + dst[i] = (unsigned char)(sample[i] ^ 0x80); + } + } + + // Build the stream structure in this slot's first 16 bytes. + sfx = (unsigned char *)slotBase; + sfx[0] = (unsigned char)(sampleAddr & 0xFFu); + sfx[1] = (unsigned char)((sampleAddr >> 8) & 0xFFu); + sfx[2] = (unsigned char)((sampleAddr >> 16) & 0xFFu); + sfx[3] = 0; + sfx[4] = (unsigned char)(length & 0xFFu); + sfx[5] = (unsigned char)((length >> 8) & 0xFFu); + sfx[6] = (unsigned char)((length >> 16) & 0xFFu); + sfx[7] = (unsigned char)((length >> 24) & 0xFFu); + sfx[8] = (unsigned char)(freqWord & 0xFFu); + sfx[9] = (unsigned char)((freqWord >> 8) & 0xFFu); + sfx[10] = (unsigned char)(SFX_BASE_DOC_PAGE + slot * SFX_DOC_PAGE_STEP); + sfx[11] = (unsigned char)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT); + sfx[12] = 1; // one playing osc + sfx[13] = SFX_VOLUME; + sfx[14] = SFX_CHANNEL_LEFT; + + // NTPstreamsound(structPtr in X/Y). Same 24-bit address packing + // pattern as NTPprepare: low 16 in X, bank in Y. + buildCallStub(gNTPBase + 24, + (uint16_t)(structAddr & 0xFFFFu), + (uint16_t)((structAddr >> 16) & 0xFFu), + 0); + asm { + jsl gCallStub + } +} + + +void halAudioStopMod(void) { + if (!gNTPReady || !gNTPPlaying) { + return; + } + buildCallStub(gNTPBase + 6, 0, 0, 0); + asm { + jsl gCallStub + } + gNTPPlaying = false; +} + + +void halAudioStopSfx(uint8_t slot) { + uint8_t firstOsc; + uint8_t i; + + if (!gNTPReady || slot >= JOEY_AUDIO_SFX_SLOTS) { + return; + } + + firstOsc = (uint8_t)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT); + + // NTP has no SFX-stop entry point. We poke DOC oscillator control + // registers directly to halt the slot's osc pair. SEI/CLI keeps + // NTP's DOC IRQ from racing our sound_address writes. + asm { + sei + } + *DOC_SOUND_CONTROL = *DOC_IRQ_VOLUME; + for (i = 0; i < SFX_OSCS_PER_SLOT; i++) { + *DOC_SOUND_ADDRESS = (uint8_t)(DOC_OSC_CTRL_BASE + firstOsc + i); + *DOC_SOUND_DATA = DOC_OSC_HALT_BITS; + } + asm { + cli + } +} + + +void halAudioFrameTick(void) { + // NTP is DOC-IRQ driven; nothing for the host to pump. +} diff --git a/src/port/iigs/hal.c b/src/port/iigs/hal.c index b112df1..99ecffb 100644 --- a/src/port/iigs/hal.c +++ b/src/port/iigs/hal.c @@ -45,6 +45,33 @@ static uint8_t gPreviousNewVideo = 0; static bool gModeSet = false; +// Last-uploaded SCB and palette. Both registers live in bank $E1; on a +// 2.8 MHz 65816 the 200+512-byte memcpy across the bank boundary is a +// real cost when it runs every present. Caching here lets the typical +// game loop (which mutates pixels but rarely SCB/palette) skip the +// upload entirely on clean frames. +static uint8_t gCachedScb [SURFACE_HEIGHT]; +static uint16_t gCachedPalette[SURFACE_PALETTE_COUNT][SURFACE_COLORS_PER_PALETTE]; +static bool gCacheValid = false; + +// Upload SCB and palette into bank-$E1 SHR memory only when they have +// changed since the last call. paletteOrScbChanged returns false when +// the cache is already in sync, in which case both memcpys to $E1 are +// skipped. +static void uploadScbAndPaletteIfNeeded(const SurfaceT *src) { + if (gCacheValid + && memcmp(gCachedScb, src->scb, sizeof(gCachedScb)) == 0 + && memcmp(gCachedPalette, src->palette, sizeof(gCachedPalette)) == 0) { + return; + } + memcpy(IIGS_SHR_SCB, src->scb, SURFACE_HEIGHT); + memcpy(IIGS_SHR_PALETTE, src->palette, sizeof(src->palette)); + memcpy(gCachedScb, src->scb, sizeof(gCachedScb)); + memcpy(gCachedPalette, src->palette, sizeof(gCachedPalette)); + gCacheValid = true; +} + + // ----- HAL API (alphabetical) ----- bool halInit(const JoeyConfigT *config) { @@ -65,9 +92,8 @@ void halPresent(const SurfaceT *src) { if (src == NULL) { return; } - memcpy(IIGS_SHR_PIXELS, src->pixels, SURFACE_PIXELS_SIZE); - memcpy(IIGS_SHR_SCB, src->scb, SURFACE_HEIGHT); - memcpy(IIGS_SHR_PALETTE, src->palette, sizeof(src->palette)); + uploadScbAndPaletteIfNeeded(src); + memcpy(IIGS_SHR_PIXELS, src->pixels, SURFACE_PIXELS_SIZE); } @@ -81,12 +107,7 @@ void halPresentRect(const SurfaceT *src, int16_t x, int16_t y, uint16_t w, uint1 return; } - // SCBs and palettes track the whole surface, not just the rect -- - // cheap enough on IIgs (200 bytes + 512 bytes) and avoids tracking - // which palette/SCB regions changed. A future optimization can - // limit these to the affected scanlines. - memcpy(IIGS_SHR_SCB, src->scb, SURFACE_HEIGHT); - memcpy(IIGS_SHR_PALETTE, src->palette, sizeof(src->palette)); + uploadScbAndPaletteIfNeeded(src); // Pixel copy: byte-aligned runs per scanline. x is always even // after API-level clipping for 4bpp packed if caller aligned it; diff --git a/src/port/iigs/input.c b/src/port/iigs/input.c index 18089d1..b54554b 100644 --- a/src/port/iigs/input.c +++ b/src/port/iigs/input.c @@ -197,29 +197,42 @@ static void pollJoystick(void) { uint8_t px; uint8_t py; uint8_t byte; + bool xResolved; + bool yResolved; - byte = *IIGS_PTRIG; - px = 255; + // One PTRIG read starts BOTH paddle timers simultaneously per the + // IIgs Hardware Reference. Polling them in parallel halves the + // wall-clock time vs. polling each serially after its own trigger. + byte = *IIGS_PTRIG; + px = 0; + py = 0; + xResolved = false; + yResolved = false; for (count = 0; count < PADDLE_TIMEOUT; count++) { - byte = *IIGS_PADDLE0; - if ((byte & IIGS_PADDLE_BUSY) == 0) { - px = (uint8_t)count; + if (!xResolved) { + byte = *IIGS_PADDLE0; + if ((byte & IIGS_PADDLE_BUSY) == 0) { + px = (uint8_t)count; + xResolved = true; + } + } + if (!yResolved) { + byte = *IIGS_PADDLE1; + if ((byte & IIGS_PADDLE_BUSY) == 0) { + py = (uint8_t)count; + yResolved = true; + } + } + if (xResolved && yResolved) { break; } } - byte = *IIGS_PTRIG; - py = 255; - for (count = 0; count < PADDLE_TIMEOUT; count++) { - byte = *IIGS_PADDLE1; - if ((byte & IIGS_PADDLE_BUSY) == 0) { - py = (uint8_t)count; - break; - } - } - - gJoyAxisX[JOYSTICK_0] = thresholdPaddle(px); - gJoyAxisY[JOYSTICK_0] = thresholdPaddle(py); + // Timed-out paddles default to centered axis. Without an explicit + // resolved flag we couldn't distinguish "no joystick" from "stick + // hard right" -- both would yield px=255 and report AXIS_MAX. + gJoyAxisX[JOYSTICK_0] = xResolved ? thresholdPaddle(px) : 0; + gJoyAxisY[JOYSTICK_0] = yResolved ? thresholdPaddle(py) : 0; gJoyButtonState[JOYSTICK_0][JOY_BUTTON_0] = (*IIGS_BTN0 & IIGS_BUTTON_BIT) != 0; gJoyButtonState[JOYSTICK_0][JOY_BUTTON_1] = (*IIGS_BTN1 & IIGS_BUTTON_BIT) != 0; diff --git a/toolchains/install.sh b/toolchains/install.sh index 9c525bf..ad3b36f 100755 --- a/toolchains/install.sh +++ b/toolchains/install.sh @@ -180,11 +180,79 @@ should_install() { # IIgs: Merlin32 (free), ORCA/C (manual), GoldenGate (manual) # ------------------------------------------------------------------------ +install_audio_libxmp() { + header "libxmp-lite (DOS / ST audio decoder)" + local base="${SCRIPT_DIR}/audio" + local target="${base}/libxmp-lite" + local key="audio_libxmp" + local version="4.7.0" + + if [ "${FORCE_INSTALL}" -eq 1 ]; then + rm -rf "${target}" + clear_done "${key}" + fi + if is_done "${key}" && [ -f "${target}/include/libxmp-lite/xmp.h" ]; then + ok "libxmp-lite ${version} already installed" + STATUS[${key}]="ok" + return + fi + + info "Downloading libxmp-lite ${version} from libxmp.org" + local tarball="${CACHE_DIR}/libxmp-lite-${version}.tar.gz" + local url="https://github.com/libxmp/libxmp/releases/download/libxmp-${version}/libxmp-lite-${version}.tar.gz" + if ! download "${url}" "${tarball}"; then + STATUS[${key}]="failed" + INSTRUCTIONS[${key}]="Could not download ${url}" + return + fi + + mkdir -p "${base}" + rm -rf "${target}" + if (cd "${base}" && tar xzf "${tarball}") && [ -d "${base}/libxmp-lite-${version}" ]; then + mv "${base}/libxmp-lite-${version}" "${target}" + mark_done "${key}" + ok "libxmp-lite installed at ${target}" + STATUS[${key}]="ok" + else + STATUS[${key}]="failed" + INSTRUCTIONS[${key}]="Extract of ${tarball} did not produce libxmp-lite-${version}/" + fi +} + + install_iigs() { header "IIgs toolchain" local base="${SCRIPT_DIR}/iigs" mkdir -p "${base}" + # PHP CLI is required by tools/joeymod when converting .MOD files + # to NinjaTrackerPlus's .NTP runtime format. Installed once here so + # the IIgs audio asset pipeline works out of the box. + if command -v php >/dev/null 2>&1; then + ok "php CLI already on PATH" + STATUS[iigs_php]="ok" + else + info "Installing php CLI for joeymod's MOD->NTP converter" + local php_pkgs="" + case "${HOST_OS}" in + linux) php_pkgs="php-cli" ;; + macos) php_pkgs="php" ;; + esac + if [ -n "${php_pkgs}" ] && pkg_install ${php_pkgs}; then + ok "php installed" + STATUS[iigs_php]="ok" + else + STATUS[iigs_php]="missing" + INSTRUCTIONS[iigs_php]=$(cat < .NTP conversion path. + Debian/Ubuntu: sudo apt install php-cli + macOS Homebrew: brew install php +EOF +) + fi + fi + # Merlin32 -- 65816 cross-assembler from Brutal Deluxe. # The canonical distribution is a zip on brutaldeluxe.fr that bundles # prebuilt binaries for Linux / macOS / Windows plus the C source. @@ -465,6 +533,51 @@ EOF fi fi fi + + # NinjaTrackerPlus -- Ninjaforce's IIgs MOD player. Distributed as + # source (ZIP) plus binary tool disk images on ninjaforce.com. + # Redistribution is granted by the copyright holder per the user's + # standing permission. We download the source archive (extracted + # into ${base}/ntp), the demo disk (kept in cache), and the tool + # disk (kept in cache) so a fresh checkout can build the IIgs + # audio HAL without manual intervention. + if [ "${FORCE_INSTALL}" -eq 1 ]; then + rm -rf "${base}/ntp" + clear_done "iigs_ntp" + fi + if is_done "iigs_ntp" && [ -f "${base}/ntp/ninjatrackerplus.s" ]; then + ok "NinjaTrackerPlus already installed" + STATUS[iigs_ntp]="ok" + else + info "Downloading NinjaTrackerPlus from ninjaforce.com" + local ntp_src_zip="${CACHE_DIR}/ntpsources.zip" + local ntp_demo="${CACHE_DIR}/ntp.2mg" + local ntp_tool="${CACHE_DIR}/ninjatrackerplus_tool222_v1.4.2mg" + local ntp_failed=0 + download "https://www.ninjaforce.com/downloads/ntpsources.zip" "${ntp_src_zip}" || ntp_failed=1 + download "https://www.ninjaforce.com/downloads/ntp.2mg" "${ntp_demo}" || true + download "https://www.ninjaforce.com/downloads/ninjatrackerplus_tool222_v1.4.2mg" "${ntp_tool}" || true + if [ "${ntp_failed}" -eq 1 ]; then + STATUS[iigs_ntp]="failed" + INSTRUCTIONS[iigs_ntp]="Could not download ntpsources.zip from www.ninjaforce.com" + else + mkdir -p "${base}/ntp" + if (cd "${base}/ntp" && unzip -oq "${ntp_src_zip}") && [ -f "${base}/ntp/ninjatrackerplus.s" ]; then + # ntpsources.zip ships .s files with CRLF line + # endings; Merlin32 rejects those as "Unknown line". + # Normalize once here so the build doesn't have to. + for f in "${base}/ntp"/*.s; do + sed -i 's/\r$//' "$f" + done + mark_done "iigs_ntp" + ok "NinjaTrackerPlus installed at ${base}/ntp" + STATUS[iigs_ntp]="ok" + else + STATUS[iigs_ntp]="failed" + INSTRUCTIONS[iigs_ntp]="Extract of ${ntp_src_zip} did not produce ninjatrackerplus.s" + fi + fi + fi } # ------------------------------------------------------------------------ @@ -1317,6 +1430,14 @@ main() { if should_install atarist; then install_atarist; fi if should_install dos; then install_dos; fi + # Audio third-party fetches that span multiple targets. libxmp-lite + # is used by the DOS HAL today and will back the ST mixer's + # Protracker decoder later, so it lives in toolchains/audio/ rather + # than under any one platform's tree. + if should_install dos || should_install atarist; then + install_audio_libxmp + fi + if [ "${SKIP_EMULATORS}" -eq 0 ] && [ -z "${ONLY_PLATFORM}" ]; then install_emulators fi diff --git a/tools/joeymod/joeymod b/tools/joeymod/joeymod new file mode 100755 index 0000000..b9f26a5 --- /dev/null +++ b/tools/joeymod/joeymod @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# joeymod: convert a Protracker .MOD into the runtime form a target +# platform expects. +# +# Usage: +# joeymod input.mod output.mod -- passthrough copy + magic validation +# (DOS / ST runtimes detect song end +# inside libxmp-lite, so the file is +# used as-is) +# joeymod input.mod output.amod -- passthrough + E8FF stop-marker +# pattern injected at song end. The +# Amiga audio HAL relies on E8FF to +# honor PlayMod(loop=false) since +# PTPlayer has no native song-end +# signal. Other replayers ignore +# E8FF (or treat it as a filter +# toggle), so .amod is harmless if +# fed to the wrong port. +# joeymod input.mod output.ntp -- runs the PHP ntpconverter staged +# at toolchains/iigs/ntp/ntpconverter.php +# (NinjaTrackerPlus' .NTP runtime +# format on the IIgs) +# +# The output extension picks the conversion. Build rules per platform +# call this with whichever pair their HAL needs. + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: joeymod input.mod output.{mod,amod,ntp}" >&2 + exit 2 +fi + +in=$1 +out=$2 + +if [[ ! -f $in ]]; then + echo "joeymod: input not found: $in" >&2 + exit 2 +fi + +# All Protracker-derived .MOD variants store a 4-byte signature at +# offset 1080. Reject anything that does not match a known one -- +# better to fail at build time than ship a corrupted audio asset. +magic=$(dd if="$in" bs=1 count=4 skip=1080 status=none) +case "$magic" in + M.K.|M!K!|FLT4|FLT8|6CHN|8CHN|CD81|OKTA|OCTA) ;; + *) + printf 'joeymod: %s does not look like a .MOD (magic %q at offset 1080)\n' "$in" "$magic" >&2 + exit 2 + ;; +esac + +ext="${out##*.}" +ext="${ext,,}" + +case "$ext" in + mod) + cp "$in" "$out" + ;; + amod) + repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + modloopend="$repo/build/tools/modloopend" + if [[ ! -x $modloopend ]]; then + echo "joeymod: $modloopend missing; run 'make tools' first" >&2 + exit 2 + fi + "$modloopend" "$in" "$out" + ;; + ntp) + repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + ntp_dir="$repo/toolchains/iigs/ntp" + if [[ ! -f $ntp_dir/ntpconverter.php ]]; then + echo "joeymod: $ntp_dir/ntpconverter.php missing; run toolchains/install.sh" >&2 + exit 2 + fi + if ! command -v php >/dev/null 2>&1; then + echo "joeymod: php CLI not found; install php-cli" >&2 + exit 2 + fi + # ntpconverter.php writes .ntp next to its input. Stage + # in a scratch dir so we own the output path and don't pollute + # the caller's source tree. + tmp=$(mktemp -d -t joeymod.XXXXXX) + trap 'rm -rf "$tmp"' EXIT + cp "$in" "$tmp/song.MOD" + ( cd "$ntp_dir" && php ntpconverter.php "$tmp/song.MOD" >/dev/null ) + if [[ ! -f $tmp/song.ntp ]]; then + echo "joeymod: ntpconverter.php did not produce an .ntp from $in" >&2 + exit 2 + fi + cp "$tmp/song.ntp" "$out" + ;; + *) + echo "joeymod: unknown output extension '.$ext' (expected .mod, .amod, or .ntp)" >&2 + exit 2 + ;; +esac + +printf 'joeymod: %s -> %s (%d bytes)\n' "$in" "$out" "$(stat -c %s "$out")" diff --git a/tools/modloopend/modloopend.c b/tools/modloopend/modloopend.c new file mode 100644 index 0000000..2ad623a --- /dev/null +++ b/tools/modloopend/modloopend.c @@ -0,0 +1,221 @@ +// Append a one-shot stop marker to a Protracker .MOD file so the Amiga +// audio HAL (PTPlayer-based) can honor `joeyAudioPlayMod(loop=false)`. +// +// PTPlayer has no built-in song-end signal -- songs loop forever +// unless an `E8FF` effect command is encountered, which latches into +// the public `mt_E8Trigger` byte. Our halAudioFrameTick polls that +// byte and clears mt_Enable when the marker fires, but the marker has +// to actually be IN the .MOD for the polling to ever match. +// +// This tool injects a new pattern at the end of the song that +// contains exactly one E8FF effect on row 0 and is otherwise empty. +// The order table is extended to play that pattern once after the +// existing song. Result: the song plays normally, then the marker +// pattern fires, mt_E8Trigger goes 0xFF, and PlayMod(loop=false) +// stops cleanly. Other replayers (libxmp on DOS/ST, NTP on IIgs) +// either ignore E8FF or treat it as a no-op filter command -- on +// those ports the engine's own song-end detection is what stops the +// song, so the injected marker is harmless there. +// +// Usage: modloopend input.mod output.mod +// +// The existing pattern data is not modified -- only the order table, +// song length, and a new pattern at the end are written. + +#include +#include +#include +#include +#include + +#define MOD_HEADER_SIZE 1084 +#define MOD_SONG_LENGTH_OFFSET 950 +#define MOD_RESTART_OFFSET 951 +#define MOD_ORDER_TABLE_OFFSET 952 +#define MOD_ORDER_TABLE_LEN 128 +#define MOD_MAGIC_OFFSET 1080 +#define MOD_MAGIC_LEN 4 +#define MOD_PATTERN_BYTES 1024 +#define MOD_MAX_PATTERNS 128 +#define MOD_MIN_BYTES (MOD_HEADER_SIZE + MOD_PATTERN_BYTES) + +// E8FF on row 0, channel 0. Per Protracker pattern row layout: +// byte 0 (SSSSPPPP): sample hi nibble + period hi nibble = 0x00 +// byte 1 (PPPPPPPP): period lo byte = 0x00 +// byte 2 (SSSSEEEE): sample lo nibble + effect cmd = 0x0E +// byte 3 (PPPPPPPP): effect param = 0xFF +#define E8FF_BYTE_0 0x00 +#define E8FF_BYTE_1 0x00 +#define E8FF_BYTE_2 0x0E +#define E8FF_BYTE_3 0xFF + + +// ----- Prototypes ----- + +static bool magicMatches(const uint8_t *header); +static int process(const char *inPath, const char *outPath); + + +// ----- Internal helpers (alphabetical) ----- + +static bool magicMatches(const uint8_t *header) { + static const char *known[] = { + "M.K.", "M!K!", "FLT4", "FLT8", "6CHN", "8CHN", "CD81", "OKTA", "OCTA" + }; + size_t i; + + for (i = 0; i < sizeof(known) / sizeof(known[0]); i++) { + if (memcmp(header + MOD_MAGIC_OFFSET, known[i], MOD_MAGIC_LEN) == 0) { + return true; + } + } + return false; +} + + +static int process(const char *inPath, const char *outPath) { + FILE *fp; + uint8_t *buf; + long fileSize; + size_t readBytes; + size_t newSize; + int i; + int songLength; + int maxPattern; + int patternCount; + long samplesOffset; + long samplesLen; + uint8_t *newBuf; + uint8_t *newPatternStart; + + fp = fopen(inPath, "rb"); + if (fp == NULL) { + fprintf(stderr, "modloopend: cannot open %s\n", inPath); + return 2; + } + + if (fseek(fp, 0, SEEK_END) != 0) { + fprintf(stderr, "modloopend: seek failed on %s\n", inPath); + fclose(fp); + return 2; + } + fileSize = ftell(fp); + if (fileSize < MOD_MIN_BYTES) { + fprintf(stderr, "modloopend: %s is %ld bytes, too small for a MOD\n", inPath, fileSize); + fclose(fp); + return 2; + } + rewind(fp); + + buf = (uint8_t *)malloc((size_t)fileSize); + if (buf == NULL) { + fprintf(stderr, "modloopend: out of memory\n"); + fclose(fp); + return 2; + } + readBytes = fread(buf, 1, (size_t)fileSize, fp); + fclose(fp); + if (readBytes != (size_t)fileSize) { + fprintf(stderr, "modloopend: short read on %s\n", inPath); + free(buf); + return 2; + } + + if (!magicMatches(buf)) { + fprintf(stderr, "modloopend: %s does not have a Protracker magic at offset 1080\n", inPath); + free(buf); + return 2; + } + + songLength = buf[MOD_SONG_LENGTH_OFFSET]; + if (songLength <= 0 || songLength >= MOD_ORDER_TABLE_LEN) { + fprintf(stderr, "modloopend: %s has unusable song length %d\n", inPath, songLength); + free(buf); + return 2; + } + + maxPattern = 0; + for (i = 0; i < MOD_ORDER_TABLE_LEN; i++) { + int p = buf[MOD_ORDER_TABLE_OFFSET + i]; + if (p > maxPattern) { + maxPattern = p; + } + } + patternCount = maxPattern + 1; + if (patternCount >= MOD_MAX_PATTERNS) { + fprintf(stderr, "modloopend: %s already uses pattern %d, no room for stop marker\n", + inPath, maxPattern); + free(buf); + return 2; + } + + samplesOffset = MOD_HEADER_SIZE + (long)patternCount * MOD_PATTERN_BYTES; + if (samplesOffset > fileSize) { + fprintf(stderr, "modloopend: %s claims %d patterns but file is only %ld bytes\n", + inPath, patternCount, fileSize); + free(buf); + return 2; + } + samplesLen = fileSize - samplesOffset; + + newSize = (size_t)fileSize + MOD_PATTERN_BYTES; + newBuf = (uint8_t *)malloc(newSize); + if (newBuf == NULL) { + fprintf(stderr, "modloopend: out of memory\n"); + free(buf); + return 2; + } + + // Copy header + existing patterns. + memcpy(newBuf, buf, (size_t)samplesOffset); + + // New pattern: 1024 bytes of zeros except row 0 channel 0 = E8FF. + newPatternStart = newBuf + samplesOffset; + memset(newPatternStart, 0, MOD_PATTERN_BYTES); + newPatternStart[0] = E8FF_BYTE_0; + newPatternStart[1] = E8FF_BYTE_1; + newPatternStart[2] = E8FF_BYTE_2; + newPatternStart[3] = E8FF_BYTE_3; + + // Append samples after the new pattern. + if (samplesLen > 0) { + memcpy(newBuf + samplesOffset + MOD_PATTERN_BYTES, buf + samplesOffset, (size_t)samplesLen); + } + + // Patch song length: now plays one extra position pointing at the + // new pattern index. + newBuf[MOD_SONG_LENGTH_OFFSET] = (uint8_t)(songLength + 1); + newBuf[MOD_ORDER_TABLE_OFFSET + songLength] = (uint8_t)patternCount; + + free(buf); + + fp = fopen(outPath, "wb"); + if (fp == NULL) { + fprintf(stderr, "modloopend: cannot open %s for writing\n", outPath); + free(newBuf); + return 2; + } + if (fwrite(newBuf, 1, newSize, fp) != newSize) { + fprintf(stderr, "modloopend: short write on %s\n", outPath); + fclose(fp); + free(newBuf); + return 2; + } + fclose(fp); + free(newBuf); + + printf("modloopend: %s -> %s (added pattern %d as stop marker, %zu bytes)\n", + inPath, outPath, patternCount, newSize); + return 0; +} + + +// ----- Public API (alphabetical) ----- + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: modloopend input.mod output.mod\n"); + return 2; + } + return process(argv[1], argv[2]); +}