// Append a one-shot stop marker to a Protracker .MOD file so the Amiga // audio HAL (PTPlayer-based) can honor `joeyAudioPlayMod(loop=false)`. // // PTPlayer has no built-in song-end signal -- songs loop forever // unless an `E8FF` effect command is encountered, which latches into // the public `mt_E8Trigger` byte. Our halAudioFrameTick polls that // byte and clears mt_Enable when the marker fires, but the marker has // to actually be IN the .MOD for the polling to ever match. // // This tool injects a new pattern at the end of the song that // contains exactly one E8FF effect on row 0 and is otherwise empty. // The order table is extended to play that pattern once after the // existing song. Result: the song plays normally, then the marker // pattern fires, mt_E8Trigger goes 0xFF, and PlayMod(loop=false) // stops cleanly. Other replayers (libxmp on DOS/ST, NTP on IIgs) // either ignore E8FF or treat it as a no-op filter command -- on // those ports the engine's own song-end detection is what stops the // song, so the injected marker is harmless there. // // Usage: modloopend input.mod output.mod // // The existing pattern data is not modified -- only the order table, // song length, and a new pattern at the end are written. #include #include #include #include #include #define MOD_HEADER_SIZE 1084 #define MOD_SONG_LENGTH_OFFSET 950 #define MOD_RESTART_OFFSET 951 #define MOD_ORDER_TABLE_OFFSET 952 #define MOD_ORDER_TABLE_LEN 128 #define MOD_MAGIC_OFFSET 1080 #define MOD_MAGIC_LEN 4 #define MOD_PATTERN_BYTES 1024 #define MOD_MAX_PATTERNS 128 #define MOD_MIN_BYTES (MOD_HEADER_SIZE + MOD_PATTERN_BYTES) // E8FF on row 0, channel 0. Per Protracker pattern row layout: // byte 0 (SSSSPPPP): sample hi nibble + period hi nibble = 0x00 // byte 1 (PPPPPPPP): period lo byte = 0x00 // byte 2 (SSSSEEEE): sample lo nibble + effect cmd = 0x0E // byte 3 (PPPPPPPP): effect param = 0xFF #define E8FF_BYTE_0 0x00 #define E8FF_BYTE_1 0x00 #define E8FF_BYTE_2 0x0E #define E8FF_BYTE_3 0xFF // ----- Prototypes ----- static bool magicMatches(const uint8_t *header); static int process(const char *inPath, const char *outPath); // ----- Internal helpers (alphabetical) ----- static bool magicMatches(const uint8_t *header) { static const char *known[] = { "M.K.", "M!K!", "FLT4", "FLT8", "6CHN", "8CHN", "CD81", "OKTA", "OCTA" }; size_t i; for (i = 0; i < sizeof(known) / sizeof(known[0]); i++) { if (memcmp(header + MOD_MAGIC_OFFSET, known[i], MOD_MAGIC_LEN) == 0) { return true; } } return false; } static int process(const char *inPath, const char *outPath) { FILE *fp; uint8_t *buf; long fileSize; size_t readBytes; size_t newSize; int i; int songLength; int maxPattern; int patternCount; long samplesOffset; long samplesLen; uint8_t *newBuf; uint8_t *newPatternStart; fp = fopen(inPath, "rb"); if (fp == NULL) { fprintf(stderr, "modloopend: cannot open %s\n", inPath); return 2; } if (fseek(fp, 0, SEEK_END) != 0) { fprintf(stderr, "modloopend: seek failed on %s\n", inPath); fclose(fp); return 2; } fileSize = ftell(fp); if (fileSize < MOD_MIN_BYTES) { fprintf(stderr, "modloopend: %s is %ld bytes, too small for a MOD\n", inPath, fileSize); fclose(fp); return 2; } rewind(fp); buf = (uint8_t *)malloc((size_t)fileSize); if (buf == NULL) { fprintf(stderr, "modloopend: out of memory\n"); fclose(fp); return 2; } readBytes = fread(buf, 1, (size_t)fileSize, fp); fclose(fp); if (readBytes != (size_t)fileSize) { fprintf(stderr, "modloopend: short read on %s\n", inPath); free(buf); return 2; } if (!magicMatches(buf)) { fprintf(stderr, "modloopend: %s does not have a Protracker magic at offset 1080\n", inPath); free(buf); return 2; } songLength = buf[MOD_SONG_LENGTH_OFFSET]; if (songLength <= 0 || songLength >= MOD_ORDER_TABLE_LEN) { fprintf(stderr, "modloopend: %s has unusable song length %d\n", inPath, songLength); free(buf); return 2; } maxPattern = 0; for (i = 0; i < MOD_ORDER_TABLE_LEN; i++) { int p = buf[MOD_ORDER_TABLE_OFFSET + i]; if (p > maxPattern) { maxPattern = p; } } patternCount = maxPattern + 1; if (patternCount >= MOD_MAX_PATTERNS) { fprintf(stderr, "modloopend: %s already uses pattern %d, no room for stop marker\n", inPath, maxPattern); free(buf); return 2; } samplesOffset = MOD_HEADER_SIZE + (long)patternCount * MOD_PATTERN_BYTES; if (samplesOffset > fileSize) { fprintf(stderr, "modloopend: %s claims %d patterns but file is only %ld bytes\n", inPath, patternCount, fileSize); free(buf); return 2; } samplesLen = fileSize - samplesOffset; newSize = (size_t)fileSize + MOD_PATTERN_BYTES; newBuf = (uint8_t *)malloc(newSize); if (newBuf == NULL) { fprintf(stderr, "modloopend: out of memory\n"); free(buf); return 2; } // Copy header + existing patterns. memcpy(newBuf, buf, (size_t)samplesOffset); // New pattern: 1024 bytes of zeros except row 0 channel 0 = E8FF. newPatternStart = newBuf + samplesOffset; memset(newPatternStart, 0, MOD_PATTERN_BYTES); newPatternStart[0] = E8FF_BYTE_0; newPatternStart[1] = E8FF_BYTE_1; newPatternStart[2] = E8FF_BYTE_2; newPatternStart[3] = E8FF_BYTE_3; // Append samples after the new pattern. if (samplesLen > 0) { memcpy(newBuf + samplesOffset + MOD_PATTERN_BYTES, buf + samplesOffset, (size_t)samplesLen); } // Patch song length: now plays one extra position pointing at the // new pattern index. newBuf[MOD_SONG_LENGTH_OFFSET] = (uint8_t)(songLength + 1); newBuf[MOD_ORDER_TABLE_OFFSET + songLength] = (uint8_t)patternCount; free(buf); fp = fopen(outPath, "wb"); if (fp == NULL) { fprintf(stderr, "modloopend: cannot open %s for writing\n", outPath); free(newBuf); return 2; } if (fwrite(newBuf, 1, newSize, fp) != newSize) { fprintf(stderr, "modloopend: short write on %s\n", outPath); fclose(fp); free(newBuf); return 2; } fclose(fp); free(newBuf); printf("modloopend: %s -> %s (added pattern %d as stop marker, %zu bytes)\n", inPath, outPath, patternCount, newSize); return 0; } // ----- Public API (alphabetical) ----- int main(int argc, char **argv) { if (argc != 3) { fprintf(stderr, "usage: modloopend input.mod output.mod\n"); return 2; } return process(argv[1], argv[2]); }