MOD and SFX support.

This commit is contained in:
Scott Duensing 2026-04-25 13:11:46 -05:00
parent 3e9c7f4926
commit af6230e696
36 changed files with 3009 additions and 93 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.mod filter=lfs diff=lfs merge=lfs -text
*.sfx filter=lfs diff=lfs merge=lfs -text

8
.gitignore vendored
View file

@ -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

View file

@ -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/<plat>/ 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

BIN
assets/test.mod (Stored with Git LFS) Normal file

Binary file not shown.

BIN
assets/test.sfx (Stored with Git LFS) Normal file

Binary file not shown.

120
examples/audio/audio.c Normal file
View file

@ -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 <stdio.h>
#include <joey/joey.h>
#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;
}

View file

@ -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

73
include/joey/audio.h Normal file
View file

@ -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

View file

@ -15,5 +15,6 @@
#include "draw.h"
#include "present.h"
#include "input.h"
#include "audio.h"
#endif

View file

@ -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
# <SDI_compiler.h> 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)

View file

@ -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)

View file

@ -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:

View file

@ -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,6 +53,13 @@ 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
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)

View file

@ -3,6 +3,9 @@
# These build with the host's default `cc` (no cross-toolchain) into
# build/tools/. Currently:
# 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
@ -14,13 +17,19 @@ HOST_CFLAGS := -std=c99 -Wall -Wextra -O2
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)

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

80
src/core/audio.c Normal file
View file

@ -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 <stddef.h>
#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();
}

53
src/core/audioSfxMix.c Normal file
View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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

256
src/port/amiga/audio.c Normal file
View file

@ -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 <string.h>
#include <exec/types.h>
#include <exec/memory.h>
#include <hardware/custom.h>
#include <proto/exec.h>
#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;
}
}

View file

@ -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);
}

251
src/port/atarist/audio.c Normal file
View file

@ -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 <string.h>
#include <mint/osbind.h>
#include <libxmp-lite/xmp.h>
#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;
}
}

View file

@ -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

View file

@ -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);
}

633
src/port/dos/audio.c Normal file
View file

@ -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 <ctype.h>
#include <dos.h>
#include <dpmi.h>
#include <go32.h>
#include <pc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/farptr.h>
#include <sys/movedata.h>
#include <sys/nearptr.h>
#include <libxmp-lite/xmp.h>
#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;
}
}
}

70
src/port/iigs/audio.c Normal file
View file

@ -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) {
}

394
src/port/iigs/audio_full.c Normal file
View file

@ -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 <stdio.h>
#include <string.h>
#include <types.h>
#include <memory.h>
#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.
}

View file

@ -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;
}
uploadScbAndPaletteIfNeeded(src);
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));
}
@ -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;

View file

@ -197,29 +197,42 @@ static void pollJoystick(void) {
uint8_t px;
uint8_t py;
uint8_t byte;
bool xResolved;
bool yResolved;
// 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 = 255;
px = 0;
py = 0;
xResolved = false;
yResolved = false;
for (count = 0; count < PADDLE_TIMEOUT; count++) {
if (!xResolved) {
byte = *IIGS_PADDLE0;
if ((byte & IIGS_PADDLE_BUSY) == 0) {
px = (uint8_t)count;
break;
xResolved = true;
}
}
byte = *IIGS_PTRIG;
py = 255;
for (count = 0; count < PADDLE_TIMEOUT; count++) {
if (!yResolved) {
byte = *IIGS_PADDLE1;
if ((byte & IIGS_PADDLE_BUSY) == 0) {
py = (uint8_t)count;
yResolved = true;
}
}
if (xResolved && yResolved) {
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;

View file

@ -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 <<EOF
php CLI not installed. Required by tools/joeymod for the IIgs
.MOD -> .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

100
tools/joeymod/joeymod Executable file
View file

@ -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 <basename>.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")"

View file

@ -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 <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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]);
}