mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
tracker impl, s3m converter, larger tracker sample bin
This commit is contained in:
17
assets/disk0/tracker_play.js
Normal file
17
assets/disk0/tracker_play.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const taud = require("taud")
|
||||
|
||||
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
|
||||
if (fullFilePath === undefined) {
|
||||
println(`Usage: ${exec_args[0]} path_to.taud`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const PLAYHEAD = 0
|
||||
|
||||
println("Playing "+fullFilePath.full)
|
||||
|
||||
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.setMasterPan(PLAYHEAD, 128)
|
||||
audio.setCuePosition(PLAYHEAD, 0)
|
||||
audio.play(PLAYHEAD)
|
||||
186
assets/disk0/tracker_test.js
Normal file
186
assets/disk0/tracker_test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// Tracker Mode — Bach's Prelude in C Major (BWV 846)
|
||||
// Run from the TVDOS shell: js tracker_test.js
|
||||
// Uploads ~92 patterns on startup (takes a moment).
|
||||
|
||||
// -- Note table (12-TET, 4096-TET encoding) ------------------------------------
|
||||
// C3 = 0x4000; each semitone = 4096/12 ≈ 341.33 steps; each octave = 4096 steps.
|
||||
// Sharp suffix: s (e.g. Cs3); flat aliases also provided (e.g. Db3 = Cs3).
|
||||
// Special values: Note.OFF = key-off, Note.CUT = note cut, Note.NOP = no-op.
|
||||
var Note = (function() {
|
||||
var SEMITONE = 4096 / 12;
|
||||
var C3 = 0x4000;
|
||||
function n(oct, semi) { return Math.round(C3 + (oct - 3) * 4096 + semi * SEMITONE) & 0xFFFF; }
|
||||
var t = {};
|
||||
var names = ['C','Cs','D','Ds','E','F','Fs','G','Gs','A','As','B'];
|
||||
var flats = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'];
|
||||
for (var oct = 0; oct <= 9; oct++) {
|
||||
for (var s = 0; s < 12; s++) {
|
||||
t[names[s] + oct] = n(oct, s);
|
||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||
}
|
||||
}
|
||||
t.OFF = 0x0000; // key-off
|
||||
t.CUT = 0xFFFE; // note cut (immediate)
|
||||
t.NOP = 0xFFFF; // no-op (empty row)
|
||||
return t;
|
||||
}());
|
||||
|
||||
var PLAYHEAD = 0;
|
||||
|
||||
// -- 1. Sample: triangle wave (256 samples @ C3) --------------------------------
|
||||
var SAMPLE_LEN = 256;
|
||||
var sampleBytes = new Array(SAMPLE_LEN);
|
||||
for (var i = 0; i < SAMPLE_LEN; i++) {
|
||||
var phase = (i / SAMPLE_LEN) * 2.0;
|
||||
var val_ = phase < 1.0 ? phase : 2.0 - phase;
|
||||
sampleBytes[i] = Math.round(val_ * 254) & 0xFF;
|
||||
}
|
||||
var memBase = audio.getMemAddr();
|
||||
for (var i = 0; i < SAMPLE_LEN; i++) {
|
||||
sys.poke(memBase - i, sampleBytes[i]);
|
||||
}
|
||||
|
||||
// -- 2. Instrument 0 -----------------------------------------------------------
|
||||
var instBytes = new Array(64).fill(0);
|
||||
instBytes[2] = 0; instBytes[3] = 1; // sampleLength = 256
|
||||
instBytes[4] = 0x00; instBytes[5] = 0x7D; // samplingRate = 32000
|
||||
instBytes[10] = 0x00; instBytes[11] = 0x01; // sampleLoopEnd = 256 (whole sample)
|
||||
instBytes[12] = 1; // loopMode = 1 (forward)
|
||||
instBytes[16] = 255; instBytes[17] = 0; // envelope: vol=255, hold
|
||||
audio.uploadInstrument(0, instBytes);
|
||||
|
||||
// -- 3. Piano-roll builder -----------------------------------------------------
|
||||
// Source convention: C1=0, C2=12, C3=24, C4=36 (i.e. C3=24, octave every 12).
|
||||
function midiToTsvm(n) {
|
||||
var oct = Math.floor(n / 12) + 1;
|
||||
return Math.round(0x3000 + oct * 4096 + (n % 12) * (4096 / 12)) & 0xFFFF;
|
||||
}
|
||||
|
||||
var noteMap = {}; // absRow → TSVM note value
|
||||
var rowCursor = 0;
|
||||
|
||||
function seq(notes, lens) {
|
||||
for (var i = 0; i < notes.length; i++) {
|
||||
noteMap[rowCursor] = midiToTsvm(notes[i]);
|
||||
rowCursor += lens[i];
|
||||
}
|
||||
}
|
||||
|
||||
var TD = 3; // rows per note step (= source TICK_DIVISOR)
|
||||
|
||||
function prel(n1, n2, n3, n4, n5) {
|
||||
seq([n1, n2, n3, n4, n5, n3, n4, n5, n1, n2, n3, n4, n5, n3, n4, n5],
|
||||
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
|
||||
}
|
||||
function end1(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
|
||||
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n7, n8, n7, n8, n9, n8, n9],
|
||||
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
|
||||
}
|
||||
function end2(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
|
||||
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n4, n3, n4, n7, n8, n9, n7],
|
||||
[TD+2, TD+1, TD+1, TD+1, TD+1, TD+2, TD+2, TD+2,
|
||||
TD+3, TD+3, TD+4, TD+4, TD+6, TD+8, TD+12, TD+24]);
|
||||
}
|
||||
function end3(ns) {
|
||||
for (var i = 0; i < ns.length; i++) {
|
||||
noteMap[rowCursor] = midiToTsvm(ns[i]);
|
||||
rowCursor += 1;
|
||||
}
|
||||
for (var i = 0; i < TD*2; i++) {
|
||||
noteMap[rowCursor] = Note.NOP
|
||||
rowCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4. Build the piece --------------------------------------------------------
|
||||
rowCursor = 16 * TD; // 160-row intro silence
|
||||
|
||||
prel(24,28,31,36,40);
|
||||
prel(24,26,33,38,41);
|
||||
prel(23,26,31,38,41);
|
||||
prel(24,28,31,36,40);
|
||||
prel(24,28,33,40,45);
|
||||
prel(24,26,30,33,38);
|
||||
prel(23,26,31,38,43);
|
||||
prel(23,24,28,31,36);
|
||||
prel(21,24,28,31,36);
|
||||
prel(14,21,26,30,36);
|
||||
prel(19,23,26,31,35);
|
||||
prel(19,22,28,31,37);
|
||||
prel(17,21,26,33,38);
|
||||
prel(17,20,26,29,35);
|
||||
prel(16,19,24,31,36);
|
||||
prel(16,17,21,24,29);
|
||||
prel(14,17,21,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel(12,16,19,24,28);
|
||||
prel(12,19,22,24,28);
|
||||
prel( 5,17,21,24,28);
|
||||
prel( 6,12,21,24,27);
|
||||
prel( 8,17,23,24,26);
|
||||
prel( 7,17,19,23,26);
|
||||
prel( 7,16,19,24,28);
|
||||
prel( 7,14,19,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel( 7,15,21,24,30);
|
||||
prel( 7,16,19,24,31);
|
||||
prel( 7,14,19,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel( 0,12,19,22,28);
|
||||
end1( 0,12,17,21,24,29,21,17,14);
|
||||
end2( 0,11,31,35,38,41,26,29,28);
|
||||
end3([0,12,28,31,36]);
|
||||
|
||||
noteMap[rowCursor] = Note.OFF; // key-off at start of final silence
|
||||
rowCursor += 16 * TD - 5; // 155 more rows of silence
|
||||
|
||||
var totalRows = rowCursor; // 5836
|
||||
var NUM_ROWS = 64;
|
||||
var numPatterns = Math.ceil(totalRows / NUM_ROWS); // 92
|
||||
|
||||
// -- 5. Build and upload patterns ----------------------------------------------
|
||||
for (var p = 0; p < numPatterns; p++) {
|
||||
var patBytes = new Array(512).fill(0);
|
||||
for (var r = 0; r < NUM_ROWS; r++) {
|
||||
var absRow = p * NUM_ROWS + r;
|
||||
var noteVal = (noteMap[absRow] !== undefined) ? noteMap[absRow] : Note.NOP;
|
||||
var isOn = (noteVal !== Note.NOP && noteVal !== Note.OFF && noteVal !== Note.CUT);
|
||||
var off = r * 8;
|
||||
patBytes[off] = noteVal & 0xFF;
|
||||
patBytes[off + 1] = (noteVal >> 8) & 0xFF;
|
||||
patBytes[off + 2] = 0; // instrument 0
|
||||
patBytes[off + 3] = 63; // volume
|
||||
patBytes[off + 4] = 31; // pan (centre)
|
||||
}
|
||||
audio.uploadPattern(p, patBytes);
|
||||
}
|
||||
|
||||
// -- 6. Cue sheet: one entry per pattern, last halts -------------------------
|
||||
// Cue format: 32 bytes, 20 voices with 12-bit pattern numbers packed as:
|
||||
// bytes 0-9: low nybbles (byte i = voice i*2 in hi-nybble, voice i*2+1 in lo-nybble)
|
||||
// bytes 10-19: mid nybbles (same packing)
|
||||
// bytes 20-29: high nybbles (same packing)
|
||||
// byte 30: instruction (0=NOP, 1=Halt)
|
||||
// Voice 0 plays pattern c; voices 1-19 are disabled (0xFFF).
|
||||
for (var c = 0; c < numPatterns; c++) {
|
||||
var cueBytes = new Array(32).fill(0xFF);
|
||||
// voice 0 = c (12-bit), voice 1 = 0xFFF → byte0=(c&0xF)<<4|0xF
|
||||
cueBytes[0] = ((c & 0xF) << 4) | 0xF; // lo nybbles v0,v1
|
||||
cueBytes[10] = (((c >> 4) & 0xF) << 4) | 0xF; // mid nybbles v0,v1
|
||||
cueBytes[20] = (((c >> 8) & 0xF) << 4) | 0xF; // hi nybbles v0,v1
|
||||
cueBytes[30] = (c === numPatterns - 1) ? 0x01 : 0;
|
||||
audio.uploadCue(c, cueBytes);
|
||||
}
|
||||
|
||||
// -- 7. Playback ---------------------------------------------------------------
|
||||
// BPM=500, tickRate=1: 1 row = 5 ms; 10 rows/step × 16 steps/bar ≈ 75 bars/min.
|
||||
audio.setTrackerMode(PLAYHEAD);
|
||||
audio.setBPM(PLAYHEAD, 250);
|
||||
audio.setTickRate(PLAYHEAD, 6);
|
||||
audio.setMasterVolume(PLAYHEAD, 255);
|
||||
audio.setMasterPan(PLAYHEAD, 128);
|
||||
audio.setCuePosition(PLAYHEAD, 0);
|
||||
audio.play(PLAYHEAD);
|
||||
|
||||
println("Bach's Prelude in C Major -- " + numPatterns + " patterns loaded.");
|
||||
println("Stop: audio.stop(" + PLAYHEAD + ")");
|
||||
@@ -225,8 +225,9 @@ class TVDOSFileDescriptor {
|
||||
}
|
||||
|
||||
/** reads the file bytewise and puts it to the specified memory address
|
||||
* @param count optional -- how many bytes to read
|
||||
* @param offset optional -- how many bytes to skip initially
|
||||
* @param ptr -- where the bytes should be dumped
|
||||
* @param count -- how many bytes to read
|
||||
* @param offset -- how many bytes to skip initially from the file
|
||||
*/
|
||||
pread(ptr, count, offset) {
|
||||
this.driver.pread(this, ptr, count, offset)
|
||||
@@ -241,7 +242,9 @@ class TVDOSFileDescriptor {
|
||||
}
|
||||
|
||||
/** writes the bytes stored in the memory[ptr .. ptr+count-1] to file[offset .. offset+count-1]
|
||||
* - @param offset is optional
|
||||
* @param ptr -- where the bytes are
|
||||
* @param count -- how many bytes to write
|
||||
* @param offset -- position in the file
|
||||
*/
|
||||
pwrite(ptr, count, offset) {
|
||||
this.driver.pwrite(this, ptr, count, offset)
|
||||
@@ -1024,136 +1027,6 @@ _TVDOS.DRV.FS.NET.exists = (fd) => {
|
||||
return (0 == com.getStatusCode(port[0]))
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
// Legacy Serial filesystem, !!pending for removal!!
|
||||
|
||||
|
||||
/*const filesystem = {};
|
||||
|
||||
filesystem._toPorts = (driveLetter) => {
|
||||
if (driveLetter.toUpperCase === undefined) {
|
||||
throw Error("'"+driveLetter+"' (type: "+typeof driveLetter+") is not a valid drive letter");
|
||||
}
|
||||
var port = _TVDOS.DRIVES[driveLetter.toUpperCase()];
|
||||
if (port === undefined) {
|
||||
throw Error("Drive letter '" + driveLetter.toUpperCase() + "' does not exist");
|
||||
}
|
||||
return port
|
||||
};
|
||||
filesystem._close = (portNo) => {
|
||||
com.sendMessage(portNo, "CLOSE")
|
||||
}
|
||||
filesystem._flush = (portNo) => {
|
||||
com.sendMessage(portNo, "FLUSH")
|
||||
}
|
||||
|
||||
// @return disk status code (0 for successful operation)
|
||||
// throws if:
|
||||
// - java.lang.NullPointerException if path is null
|
||||
// - Error if operation mode is not "R", "W" nor "A"
|
||||
filesystem.open = (driveLetter, path, operationMode) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
|
||||
filesystem._flush(port[0]); filesystem._close(port[0]);
|
||||
|
||||
var mode = operationMode.toUpperCase();
|
||||
if (mode != "R" && mode != "W" && mode != "A") {
|
||||
throw Error("Unknown file opening mode: " + mode);
|
||||
}
|
||||
|
||||
com.sendMessage(port[0], "OPEN"+mode+'"'+path+'",'+port[1]);
|
||||
return com.getStatusCode(port[0]);
|
||||
};
|
||||
filesystem.getFileLen = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "GETLEN");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Reading a file failed with "+response);
|
||||
}
|
||||
return Number(com.pullMessage(port[0]));
|
||||
};
|
||||
// @return the entire contents of the file in String
|
||||
filesystem.readAll = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "READ");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Reading a file failed with "+response);
|
||||
}
|
||||
return com.pullMessage(port[0]);
|
||||
};
|
||||
filesystem.readAllBytes = (driveLetter) => {
|
||||
var str = filesystem.readAll(driveLetter);
|
||||
var bytes = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
filesystem.write = (driveLetter, string) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "WRITE"+string.length);
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Writing a file failed with "+response);
|
||||
}
|
||||
com.sendMessage(port[0], string);
|
||||
filesystem._flush(port[0]); filesystem._close(port[0]);
|
||||
};
|
||||
filesystem.writeBytes = (driveLetter, bytes) => {
|
||||
var string = btostr(bytes); // no spreading: has length limit
|
||||
filesystem.write(driveLetter, string);
|
||||
};
|
||||
filesystem.isDirectory = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "LISTFILES");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.mkDir = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "MKDIR");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
|
||||
if (response < 0 || response >= 128) {
|
||||
var status = com.getDeviceStatus(port[0]);
|
||||
throw Error("Creating a directory failed with ("+response+"): "+status.message+"\n");
|
||||
}
|
||||
return (response === 0); // possible status codes: 0 (success), 1 (fail)
|
||||
};
|
||||
filesystem.touch = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "TOUCH");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.mkFile = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "MKFILE");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.remove = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "DELETE");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
Object.freeze(filesystem);*/
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const files = {}
|
||||
|
||||
259
assets/disk0/tvdos/include/taud.mjs
Normal file
259
assets/disk0/tvdos/include/taud.mjs
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* LibTaud — Helper functions for interaction between Taud format and TSVM Tracker
|
||||
* Requires TVDOS to function.
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
// ── Format constants ────────────────────────────────────────────────────────
|
||||
|
||||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16)
|
||||
const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7))
|
||||
const SAMPLEINST_SIZE = 786432 // 770047 sample + 16384 instrument
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
const CUE_SIZE = 32 // bytes per cue entry (packed 12-bit×20 voices + instruction + pad)
|
||||
|
||||
// Signature written into the file (16 bytes, space-padded)
|
||||
const CAPTURE_SIGNATURE = "LibTaud/TSVM "
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function _peekU32LE(ptr, off) {
|
||||
return ((sys.peek(ptr+off) & 0xFF) ) |
|
||||
((sys.peek(ptr+off+1) & 0xFF) << 8 ) |
|
||||
((sys.peek(ptr+off+2) & 0xFF) << 16 ) |
|
||||
((sys.peek(ptr+off+3) & 0xFF) * 0x1000000) // avoid sign-extend
|
||||
}
|
||||
|
||||
function _pokeU32LE(ptr, off, v) {
|
||||
sys.poke(ptr+off, (v ) & 0xFF)
|
||||
sys.poke(ptr+off+1, (v >>> 8) & 0xFF)
|
||||
sys.poke(ptr+off+2, (v >>> 16) & 0xFF)
|
||||
sys.poke(ptr+off+3, (v >>> 24) & 0xFF)
|
||||
}
|
||||
|
||||
// ── uploadTaudFile ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load one song from a Taud file into the tracker hardware and configure the
|
||||
* given playhead ready to play.
|
||||
*
|
||||
* @param inFile Full path with drive letter, e.g. "A:/music/song.taud"
|
||||
* @param songIndex 0-based index of the song in the SONG TABLE
|
||||
* @param targetPlaydataSlot Playhead number (0-3) to configure
|
||||
*/
|
||||
function uploadTaudFile(inFile, songIndex, targetPlaydataSlot) {
|
||||
const drive = inFile[0].toUpperCase()
|
||||
const diskPath = inFile.substring(2)
|
||||
|
||||
const memBase = audio.getMemAddr()
|
||||
|
||||
// -- 1. Read whole file into VM memory ------------------------------------
|
||||
const fileHandle = files.open(inFile)
|
||||
|
||||
if (!fileHandle.exists) {
|
||||
throw Error("taud: file not exists")
|
||||
}
|
||||
|
||||
const fileSize = fileHandle.size
|
||||
const filePtr = sys.malloc(fileSize)
|
||||
fileHandle.pread(filePtr, fileSize, 0)
|
||||
|
||||
let pos = 0
|
||||
|
||||
// -- 2. Verify magic ------------------------------------------------------
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let magicc = sys.peek(filePtr + i)
|
||||
if (magicc !== TAUD_MAGIC[i]) {
|
||||
sys.free(filePtr)
|
||||
throw Error("taud: bad magic byte " + magicc.toString(16) + " at index " + i)
|
||||
}
|
||||
}
|
||||
pos = 8
|
||||
|
||||
// -- 3. Parse header ------------------------------------------------------
|
||||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
||||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||||
pos += 18 // skip reserved(2) + signature(16)
|
||||
// pos == 32 == TAUD_HEADER_SIZE
|
||||
|
||||
if (songIndex < 0 || songIndex >= numSongs) {
|
||||
sys.free(filePtr)
|
||||
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
||||
pos += compressedSize
|
||||
|
||||
// Write decompressed data to peripheral memory (backwards addressing:
|
||||
// peripheral byte k lives at memBase - k).
|
||||
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
|
||||
sys.poke(memBase - i, sys.peek(decompPtr + i))
|
||||
}
|
||||
sys.free(decompPtr)
|
||||
|
||||
// -- 5. Parse song-table entry for the requested song --------------------
|
||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
|
||||
let numPatsLo = sys.peek(filePtr + entryOff + 5) & 0xFF
|
||||
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
|
||||
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
|
||||
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
|
||||
|
||||
let bpm = bpmStored + 24
|
||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||
|
||||
// -- 6. Upload patterns ---------------------------------------------------
|
||||
let songBase = filePtr + songOffset
|
||||
let patBytes = new Array(PATTERN_SIZE)
|
||||
for (let p = 0; p < patsToLoad; p++) {
|
||||
for (let k = 0; k < PATTERN_SIZE; k++)
|
||||
patBytes[k] = sys.peek(songBase + p * PATTERN_SIZE + k) & 0xFF
|
||||
audio.uploadPattern(p, patBytes)
|
||||
}
|
||||
|
||||
// -- 7. Upload cue sheet --------------------------------------------------
|
||||
let cueBase = songBase + patsToLoad * PATTERN_SIZE
|
||||
let cueBytes = new Array(CUE_SIZE)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
cueBytes[k] = sys.peek(cueBase + c * CUE_SIZE + k) & 0xFF
|
||||
audio.uploadCue(c, cueBytes)
|
||||
}
|
||||
|
||||
// -- 8. Configure playhead ------------------------------------------------
|
||||
audio.setTrackerMode(targetPlaydataSlot)
|
||||
audio.setBPM(targetPlaydataSlot, bpm)
|
||||
audio.setTickRate(targetPlaydataSlot, tickRate > 0 ? tickRate : 6)
|
||||
|
||||
|
||||
fileHandle.close()
|
||||
sys.free(filePtr)
|
||||
}
|
||||
|
||||
// ── captureTrackerDataToFile ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dump the current tracker hardware state (sample bin, instruments, patterns
|
||||
* in bank 0, cue sheet) to a single-song Taud file. BPM and tick-rate are
|
||||
* taken from playhead 0.
|
||||
*
|
||||
* @param outFile Full path with drive letter, e.g. "A:/music/out.taud"
|
||||
*/
|
||||
function captureTrackerDataToFile(outFile) {
|
||||
const drive = outFile[0].toUpperCase()
|
||||
const diskPath = outFile.substring(2)
|
||||
|
||||
const memBase = audio.getMemAddr()
|
||||
const baseAddr = audio.getBaseAddr()
|
||||
|
||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
|
||||
// (memBase - k) into (sampleInstBuf + k).
|
||||
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
|
||||
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
|
||||
|
||||
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
|
||||
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
|
||||
sys.free(sampleInstBuf)
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
outer: for (let p = NUM_PATTERNS_MAX - 1; p >= 0; p--) {
|
||||
let patBase = 131072 + p * PATTERN_SIZE // offset within peripheral memory space
|
||||
for (let k = 0; k < PATTERN_SIZE; k++) {
|
||||
if ((sys.peek(memBase - (patBase + k)) & 0xFF) !== 0) {
|
||||
numPatsActual = p + 1
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numPatsActual === 0) numPatsActual = 1 // always emit at least one pattern slot
|
||||
|
||||
let numPats = numPatsActual // Uint16, 1-65535
|
||||
let patsToSave = numPatsActual
|
||||
|
||||
// -- 3. BPM / tick-rate from playhead 0 -----------------------------------
|
||||
let bpm = audio.getBPM(0) || 125
|
||||
let tickRate = audio.getTickRate(0) || 6
|
||||
let bpmStored = (bpm - 24) & 0xFF
|
||||
|
||||
// -- 4. Compute song offset (absolute from file start) --------------------
|
||||
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
|
||||
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
|
||||
|
||||
// -- 5. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(16)
|
||||
for (let i = 0; i < 16; i++)
|
||||
sigBytes[i] = i < CAPTURE_SIGNATURE.length ? CAPTURE_SIGNATURE.charCodeAt(i) : 0
|
||||
|
||||
let header = [
|
||||
// Magic (8)
|
||||
0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64,
|
||||
// version, numSongs
|
||||
TAUD_VERSION, 1,
|
||||
// compressedSize uint32 LE (4)
|
||||
(compressedSize ) & 0xFF,
|
||||
(compressedSize >>> 8) & 0xFF,
|
||||
(compressedSize >>> 16) & 0xFF,
|
||||
(compressedSize >>> 24) & 0xFF,
|
||||
// reserved (2)
|
||||
0x00, 0x00,
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 2 + 16 = 32 bytes
|
||||
|
||||
// -- 6. Build song-table row (16 bytes) -----------------------------------
|
||||
let songTable = [
|
||||
(songOffset ) & 0xFF,
|
||||
(songOffset >>> 8) & 0xFF,
|
||||
(songOffset >>> 16) & 0xFF,
|
||||
(songOffset >>> 24) & 0xFF,
|
||||
20, // numVoices
|
||||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||||
bpmStored, // BPM with −24 bias
|
||||
tickRate, // initial tick-rate
|
||||
0,0,0,0,0,0,0, // 7 bytes padding
|
||||
]
|
||||
|
||||
// -- 7. Write header (creates / truncates file) ---------------------------
|
||||
const fileHandle = files.open(outFile)
|
||||
fileHandle.bwrite(header)
|
||||
|
||||
// -- 8. Append compressed sample+inst bin ---------------------------------
|
||||
fileHandle.pwrite(compBuf, compressedSize, 32)
|
||||
sys.free(compBuf)
|
||||
|
||||
// -- 9. Write song table --------------------------------------------------
|
||||
fileHandle.bwrite(songTable)
|
||||
|
||||
// -- 10. Append pattern bin -----------------------------------------------
|
||||
let patBuf = sys.malloc(patsToSave * PATTERN_SIZE)
|
||||
sys.memcpy(memBase - 131072, patBuf, patsToSave * PATTERN_SIZE)
|
||||
fileHandle.pwrite(patBuf, patsToSave * PATTERN_SIZE, 32 + compressedSize + songTable.length)
|
||||
sys.free(patBuf)
|
||||
|
||||
// -- 11. Append cue sheet (all 1024 entries from MMIO space) --------------
|
||||
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
|
||||
// accessed as sys.peek(baseAddr − (32768 + c*32 + k)).
|
||||
let cueBuf = sys.malloc(NUM_CUES * CUE_SIZE)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
let cueOff = 32768 + c * CUE_SIZE
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
sys.poke(cueBuf + c * CUE_SIZE + k,
|
||||
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
|
||||
}
|
||||
fileHandle.pwrite(cueBuf, NUM_CUES * CUE_SIZE, 32 + compressedSize + songTable.length + patsToSave * PATTERN_SIZE)
|
||||
sys.free(cueBuf)
|
||||
|
||||
|
||||
fileHandle.flush(); fileHandle.close()
|
||||
}
|
||||
|
||||
exports = { uploadTaudFile, captureTrackerDataToFile }
|
||||
664
s3m2taud.py
Normal file
664
s3m2taud.py
Normal file
@@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env python3
|
||||
"""s3m2taud.py — Convert Scream Tracker 3 (.s3m) to TSVM Taud (.taud)
|
||||
|
||||
Usage:
|
||||
python3 s3m2taud.py input.s3m output.taud [-v]
|
||||
|
||||
Limits:
|
||||
- Up to 15 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 256).
|
||||
- Sample bin is 770048 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- AdLib instruments are skipped.
|
||||
- Effects mapped: D (vol-slide), E/F (pitch slide, rough approx),
|
||||
SC (note-cut), A (initial speed), T (initial BPM). Others dropped.
|
||||
|
||||
Pitch-slide approximation:
|
||||
Amiga-period mode: taud_arg ≈ s3m_arg * 2 (mid-register heuristic)
|
||||
Linear-slide mode: taud_arg = s3m_arg * 4 (exact)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
def vprint(*a, **kw):
|
||||
if VERBOSE:
|
||||
print(*a, **kw, file=sys.stderr)
|
||||
|
||||
|
||||
# ── S3M constants ────────────────────────────────────────────────────────────
|
||||
|
||||
S3M_MAGIC = b"SCRM"
|
||||
S3M_TYPE_PCM = 1
|
||||
S3M_NOTE_EMPTY = 0xFF
|
||||
S3M_NOTE_OFF = 0xFE
|
||||
S3M_ORDER_SKIP = 0xFE
|
||||
S3M_ORDER_END = 0xFF
|
||||
|
||||
# S3M effect letters (1-based: 1='A', 2='B', …)
|
||||
EFF_A = 1 # set speed
|
||||
EFF_B = 2 # jump to order
|
||||
EFF_C = 3 # pattern break
|
||||
EFF_D = 4 # volume slide
|
||||
EFF_E = 5 # porta down
|
||||
EFF_F = 6 # porta up
|
||||
EFF_G = 7 # tone porta
|
||||
EFF_H = 8 # vibrato
|
||||
EFF_I = 9 # tremor
|
||||
EFF_J = 10 # arpeggio
|
||||
EFF_K = 11 # vibrato+volslide
|
||||
EFF_L = 12 # porta+volslide
|
||||
EFF_M = 13 # channel vol
|
||||
EFF_N = 14 # chan vol slide
|
||||
EFF_O = 15 # sample offset
|
||||
EFF_P = 16 # pan slide
|
||||
EFF_Q = 17 # retrigger
|
||||
EFF_R = 18 # tremolo
|
||||
EFF_S = 19 # special (sub-cmds)
|
||||
EFF_T = 20 # set BPM
|
||||
EFF_U = 21 # fine vibrato
|
||||
EFF_V = 22 # global vol
|
||||
EFF_W = 23 # global vol slide
|
||||
EFF_X = 24 # set pan
|
||||
EFF_Y = 25 # panbrello
|
||||
EFF_Z = 26 # sync
|
||||
|
||||
|
||||
# ── Taud constants ───────────────────────────────────────────────────────────
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16)
|
||||
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats_lo(1)+pats_hi(1)+bpm(1)+tick(1)+pad(7)
|
||||
SAMPLEBIN_SIZE = 770048
|
||||
INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 786432
|
||||
PATTERN_ROWS = 64
|
||||
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
|
||||
NUM_PATTERNS_MAX = 4095
|
||||
NUM_CUES = 1024
|
||||
CUE_SIZE = 32 # packed 12-bit×20 voices + instruction + pad
|
||||
NUM_VOICES = 20
|
||||
SIGNATURE = b"s3m2taud/TSVM " # 16 bytes
|
||||
|
||||
# Taud note constants
|
||||
NOTE_NOP = 0xFFFF
|
||||
NOTE_KEYOFF = 0x0000
|
||||
NOTE_CUT = 0xFFFE
|
||||
TAUD_C3 = 0x4000
|
||||
|
||||
|
||||
# ── S3M parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
class S3MHeader:
|
||||
__slots__ = ('title','order_count','inst_count','pat_count',
|
||||
'flags','cwt_v','sample_type','global_vol',
|
||||
'initial_speed','initial_tempo','master_vol',
|
||||
'linear_slides','default_pan_flag',
|
||||
'channel_settings','pan_values','order_list',
|
||||
'inst_ptrs','pat_ptrs')
|
||||
|
||||
def parse_s3m(data: bytes) -> S3MHeader:
|
||||
if len(data) < 0x60:
|
||||
sys.exit("error: file too short to be S3M")
|
||||
if data[0x2C:0x30] != S3M_MAGIC:
|
||||
sys.exit("error: not an S3M file (bad magic at 0x2C)")
|
||||
|
||||
h = S3MHeader()
|
||||
h.title = data[0x00:0x1C].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
h.order_count = struct.unpack_from('<H', data, 0x20)[0]
|
||||
h.inst_count = struct.unpack_from('<H', data, 0x22)[0]
|
||||
h.pat_count = struct.unpack_from('<H', data, 0x24)[0]
|
||||
h.flags = struct.unpack_from('<H', data, 0x26)[0]
|
||||
h.cwt_v = struct.unpack_from('<H', data, 0x28)[0]
|
||||
h.sample_type = data[0x2B] # 1=signed, 2=unsigned
|
||||
h.global_vol = data[0x30]
|
||||
h.initial_speed = data[0x31] # ticks per row
|
||||
h.initial_tempo = data[0x32] # BPM
|
||||
h.master_vol = data[0x33]
|
||||
h.linear_slides = bool(h.flags & 0x40) # flag bit 6 → linear freq slides
|
||||
h.default_pan_flag = data[0x35] # 0xFC → use pan values
|
||||
|
||||
# Channel settings: bytes 0x40..0x5F; bit 7 = disabled
|
||||
h.channel_settings = list(data[0x40:0x60])
|
||||
|
||||
# Order list
|
||||
off = 0x60
|
||||
h.order_list = list(data[off:off + h.order_count])
|
||||
|
||||
# Instrument parapointers (×16 = file offset)
|
||||
off2 = off + h.order_count
|
||||
h.inst_ptrs = [struct.unpack_from('<H', data, off2 + i*2)[0] * 16
|
||||
for i in range(h.inst_count)]
|
||||
|
||||
# Pattern parapointers
|
||||
off3 = off2 + h.inst_count * 2
|
||||
h.pat_ptrs = [struct.unpack_from('<H', data, off3 + i*2)[0] * 16
|
||||
for i in range(h.pat_count)]
|
||||
|
||||
# Default pan values (if present)
|
||||
pan_off = off3 + h.pat_count * 2
|
||||
h.pan_values = []
|
||||
if h.default_pan_flag == 0xFC and pan_off + h.inst_count <= len(data):
|
||||
for i in range(h.inst_count):
|
||||
h.pan_values.append(data[pan_off + i])
|
||||
# per-channel pan is in channel settings nibbles (separate from above)
|
||||
|
||||
return h
|
||||
|
||||
|
||||
class S3MInstrument:
|
||||
__slots__ = ('itype','filename','memseg','length','loop_begin','loop_end',
|
||||
'volume','flags','c2spd','name','sample_data','signed')
|
||||
|
||||
def parse_instruments(data: bytes, h: S3MHeader) -> list:
|
||||
insts = []
|
||||
for ptr in h.inst_ptrs:
|
||||
if ptr + 0x50 > len(data):
|
||||
vprint(f" warning: instrument pointer {ptr:#x} out of range, skipping")
|
||||
insts.append(None)
|
||||
continue
|
||||
inst = S3MInstrument()
|
||||
inst.itype = data[ptr]
|
||||
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
||||
memseg_hi = data[ptr + 0x0D]
|
||||
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
||||
inst.memseg = (memseg_hi << 16) | memseg_lo
|
||||
inst.length = struct.unpack_from('<I', data, ptr + 0x10)[0]
|
||||
inst.loop_begin = struct.unpack_from('<I', data, ptr + 0x14)[0]
|
||||
inst.loop_end = struct.unpack_from('<I', data, ptr + 0x18)[0]
|
||||
inst.volume = data[ptr + 0x1C]
|
||||
inst.flags = data[ptr + 0x1F] # bit0=loop, bit1=stereo, bit2=16bit
|
||||
inst.c2spd = struct.unpack_from('<I', data, ptr + 0x20)[0] or 8363
|
||||
inst.name = data[ptr + 0x30:ptr + 0x4C].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
inst.signed = (h.sample_type == 1)
|
||||
inst.sample_data = b''
|
||||
|
||||
if inst.itype == S3M_TYPE_PCM:
|
||||
sample_off = inst.memseg * 16
|
||||
sample_len = inst.length
|
||||
is_16bit = bool(inst.flags & 4)
|
||||
is_stereo = bool(inst.flags & 2)
|
||||
if sample_off + sample_len > len(data):
|
||||
vprint(f" warning: sample '{inst.name}' data out of range, zeroing")
|
||||
inst.sample_data = bytes(min(sample_len, 256))
|
||||
else:
|
||||
raw = data[sample_off:sample_off + sample_len]
|
||||
inst.sample_data = _normalise_sample(raw, inst.signed, is_16bit, is_stereo, inst.name)
|
||||
inst.length = len(inst.sample_data)
|
||||
inst.loop_begin = min(inst.loop_begin, inst.length)
|
||||
inst.loop_end = min(inst.loop_end, inst.length)
|
||||
insts.append(inst)
|
||||
return insts
|
||||
|
||||
|
||||
def _normalise_sample(raw: bytes, signed: bool, is_16bit: bool, is_stereo: bool, name: str) -> bytes:
|
||||
"""Return unsigned 8-bit mono sample bytes."""
|
||||
out = []
|
||||
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
|
||||
i = 0
|
||||
while i + stride <= len(raw):
|
||||
if is_16bit:
|
||||
if is_stereo:
|
||||
l16 = struct.unpack_from('<h', raw, i)[0]
|
||||
r16 = struct.unpack_from('<h', raw, i+2)[0]
|
||||
s = (l16 + r16) >> 1
|
||||
else:
|
||||
s = struct.unpack_from('<h', raw, i)[0]
|
||||
v = (s >> 8) + 128
|
||||
else:
|
||||
if is_stereo:
|
||||
l8 = raw[i]; r8 = raw[i+1]
|
||||
raw_s = (l8 + r8) // 2
|
||||
else:
|
||||
raw_s = raw[i]
|
||||
if signed:
|
||||
v = ((raw_s ^ 0x80) & 0xFF) # signed→unsigned
|
||||
else:
|
||||
v = raw_s
|
||||
out.append(v & 0xFF)
|
||||
i += stride
|
||||
if is_16bit or is_stereo:
|
||||
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── S3M pattern parser ───────────────────────────────────────────────────────
|
||||
|
||||
class S3MRow:
|
||||
"""One cell in a pattern grid."""
|
||||
__slots__ = ('note','inst','vol','effect','effect_arg')
|
||||
def __init__(self):
|
||||
self.note = S3M_NOTE_EMPTY # 0xFF=empty, 0xFE=off, else (oct<<4|pitch)
|
||||
self.inst = 0
|
||||
self.vol = -1 # -1 = not set (use instrument default)
|
||||
self.effect = 0 # 1-based letter index (0=none)
|
||||
self.effect_arg = 0
|
||||
|
||||
def parse_patterns(data: bytes, h: S3MHeader) -> list:
|
||||
"""Returns list[pat_idx] of list[channel][row] → S3MRow."""
|
||||
patterns = []
|
||||
for pi, ptr in enumerate(h.pat_ptrs):
|
||||
# 32 channels × 64 rows
|
||||
grid = [[S3MRow() for _ in range(PATTERN_ROWS)] for _ in range(32)]
|
||||
if ptr == 0 or ptr + 2 > len(data):
|
||||
patterns.append(grid)
|
||||
continue
|
||||
pat_len = struct.unpack_from('<H', data, ptr)[0]
|
||||
end = min(ptr + 2 + pat_len, len(data))
|
||||
pos = ptr + 2
|
||||
row = 0
|
||||
while row < PATTERN_ROWS and pos < end:
|
||||
b = data[pos]; pos += 1
|
||||
if b == 0:
|
||||
row += 1
|
||||
continue
|
||||
ch = b & 0x1F
|
||||
has_n = bool(b & 0x20)
|
||||
has_v = bool(b & 0x40)
|
||||
has_e = bool(b & 0x80)
|
||||
cell = grid[ch][row] if ch < 32 else S3MRow()
|
||||
if has_n:
|
||||
if pos + 1 >= end: break
|
||||
cell.note = data[pos]; pos += 1
|
||||
cell.inst = data[pos]; pos += 1
|
||||
if has_v:
|
||||
if pos >= end: break
|
||||
cell.vol = data[pos]; pos += 1
|
||||
if has_e:
|
||||
if pos + 1 >= end: break
|
||||
cell.effect = data[pos]; pos += 1
|
||||
cell.effect_arg = data[pos]; pos += 1
|
||||
patterns.append(grid)
|
||||
return patterns
|
||||
|
||||
|
||||
# ── Note / effect encoding ───────────────────────────────────────────────────
|
||||
|
||||
def encode_note(s3m_note: int) -> int:
|
||||
if s3m_note == S3M_NOTE_EMPTY:
|
||||
return NOTE_NOP
|
||||
if s3m_note == S3M_NOTE_OFF:
|
||||
return NOTE_KEYOFF
|
||||
octave = (s3m_note >> 4) & 0xF
|
||||
pitch = s3m_note & 0xF
|
||||
if pitch > 11:
|
||||
return NOTE_NOP
|
||||
semitones = (octave - 4) * 12 + pitch
|
||||
val = round(TAUD_C3 + semitones * 4096 / 12)
|
||||
return max(1, min(0xFFFD, val))
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, linear: bool) -> tuple:
|
||||
"""Return (taud_op, taud_arg16) or (0, 0) for no-op."""
|
||||
if cmd == EFF_D:
|
||||
# Volume slide: same nibble layout
|
||||
return (0x0A, arg & 0xFF)
|
||||
if cmd == EFF_E:
|
||||
# Porta down
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x02, targ)
|
||||
if cmd == EFF_F:
|
||||
# Porta up
|
||||
if linear:
|
||||
targ = min(arg * 4, 0xFFFF)
|
||||
else:
|
||||
targ = min(arg * 2, 0xFFFF)
|
||||
return (0x01, targ)
|
||||
if cmd == EFF_S:
|
||||
sub = (arg >> 4) & 0xF
|
||||
val = arg & 0xF
|
||||
if sub == 0xC: # SC - note cut
|
||||
return (0xEC, val)
|
||||
return (0x00, 0x0000)
|
||||
|
||||
|
||||
# ── Taud builders ────────────────────────────────────────────────────────────
|
||||
|
||||
def _resample_linear(data: bytes, ratio: float) -> bytes:
|
||||
"""Resample bytes by ratio (< 1 = downsample) using linear interpolation."""
|
||||
if not data:
|
||||
return data
|
||||
n_out = max(1, int(len(data) * ratio))
|
||||
out = bytearray(n_out)
|
||||
for i in range(n_out):
|
||||
src = i / ratio
|
||||
i0 = int(src)
|
||||
frac = src - i0
|
||||
i1 = min(i0 + 1, len(data) - 1)
|
||||
v = data[i0] * (1.0 - frac) + data[i1] * frac
|
||||
out[i] = int(v + 0.5) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
"""
|
||||
Returns (bin_bytes[786432], offsets_list, updated_insts).
|
||||
Resamples globally if total exceeds SAMPLEBIN_SIZE.
|
||||
"""
|
||||
pcm_insts = [(i, inst) for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM and inst.sample_data]
|
||||
|
||||
total = sum(len(inst.sample_data) for _, inst in pcm_insts)
|
||||
ratio = 1.0
|
||||
if total > SAMPLEBIN_SIZE:
|
||||
ratio = SAMPLEBIN_SIZE / total
|
||||
vprint(f" info: sample bin overflow ({total} bytes); resampling all by {ratio:.4f}")
|
||||
for _, inst in pcm_insts:
|
||||
new_data = _resample_linear(inst.sample_data, ratio)
|
||||
old_len = len(inst.sample_data)
|
||||
inst.sample_data = new_data
|
||||
inst.length = len(new_data)
|
||||
inst.loop_begin = max(0, int(inst.loop_begin * ratio))
|
||||
inst.loop_end = max(0, min(int(inst.loop_end * ratio), inst.length))
|
||||
inst.c2spd = max(1, int(inst.c2spd * ratio))
|
||||
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
pos = 0
|
||||
for idx, inst in pcm_insts:
|
||||
n = min(len(inst.sample_data), SAMPLEBIN_SIZE - pos)
|
||||
if n <= 0:
|
||||
vprint(f" warning: sample bin full, dropping '{inst.name}'")
|
||||
offsets[idx] = 0
|
||||
inst.length = 0
|
||||
continue
|
||||
sample_bin[pos:pos+n] = inst.sample_data[:n]
|
||||
offsets[idx] = pos
|
||||
if n < len(inst.sample_data):
|
||||
vprint(f" warning: '{inst.name}' truncated from {len(inst.sample_data)} to {n}")
|
||||
inst.length = n
|
||||
inst.loop_end = min(inst.loop_end, n)
|
||||
pos += n
|
||||
|
||||
# Build instrument bin (256 × 64 bytes)
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
for i, inst in enumerate(instruments):
|
||||
if i >= 256:
|
||||
break
|
||||
if inst is None or inst.itype != S3M_TYPE_PCM:
|
||||
continue
|
||||
ptr = offsets.get(i, 0)
|
||||
ptr_lo = ptr & 0xFFFF
|
||||
ptr_hi = (ptr >> 16)
|
||||
s_len = min(inst.length, 65535)
|
||||
c2spd = min(inst.c2spd, 65535)
|
||||
ps = 0
|
||||
ls = min(inst.loop_begin, 65535)
|
||||
le = min(inst.loop_end, 65535)
|
||||
loop_mode = 1 if (inst.flags & 1) else 0
|
||||
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp
|
||||
|
||||
base = i * 64
|
||||
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
|
||||
struct.pack_into('<H', inst_bin, base + 2, s_len)
|
||||
struct.pack_into('<H', inst_bin, base + 4, c2spd)
|
||||
struct.pack_into('<H', inst_bin, base + 6, ps)
|
||||
struct.pack_into('<H', inst_bin, base + 8, ls)
|
||||
struct.pack_into('<H', inst_bin, base + 10, le)
|
||||
inst_bin[base + 12] = flags_byte
|
||||
# Volume envelope: hold at instrument volume (vol*4 clamped to 255)
|
||||
env_vol = min(inst.volume * 4, 255)
|
||||
inst_bin[base + 16] = env_vol # volume
|
||||
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
|
||||
|
||||
|
||||
vprint(f" instrument '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
if inst.c2spd > 65535:
|
||||
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
|
||||
|
||||
def _default_channel_pan(ch_setting: int) -> int:
|
||||
"""Return Taud pan 0..63 from S3M channel-setting byte."""
|
||||
# Bits 4-7 of channel setting are ignored; left/right from bit 3
|
||||
# Actually the channel type (0-7 left, 8-15 right) encodes stereo side
|
||||
ch_type = ch_setting & 0x7F
|
||||
if 0 <= ch_type <= 7:
|
||||
return 16 # left
|
||||
elif 8 <= ch_type <= 15:
|
||||
return 47 # right
|
||||
return 31 # centre
|
||||
|
||||
|
||||
def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
|
||||
linear_slides: bool) -> bytes:
|
||||
"""Build a 512-byte Taud pattern for one S3M channel."""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = s3m_grid[ch_idx] if ch_idx < len(s3m_grid) else [S3MRow()] * PATTERN_ROWS
|
||||
for r, row in enumerate(rows[:PATTERN_ROWS]):
|
||||
note = encode_note(row.note)
|
||||
inst = max(0, row.inst - 1) # S3M 1-based → Taud 0-based
|
||||
vol = min(row.vol, 63) if row.vol >= 0 else 63
|
||||
pan = default_pan
|
||||
op, arg = encode_effect(row.effect, row.effect_arg, linear_slides)
|
||||
if row.effect != 0 and op == 0:
|
||||
eff_name = chr(ord('A') + row.effect - 1) if 1 <= row.effect <= 26 else '?'
|
||||
vprint(f" dropped effect {eff_name}{row.effect_arg:02X} at ch{ch_idx} row{r}")
|
||||
base = r * 8
|
||||
struct.pack_into('<H', out, base + 0, note)
|
||||
out[base + 2] = inst & 0xFF
|
||||
out[base + 3] = vol & 0x3F
|
||||
out[base + 4] = pan & 0x3F
|
||||
out[base + 5] = op & 0xFF
|
||||
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
|
||||
"""
|
||||
Consolidate identical 512-byte Taud patterns into a single copy.
|
||||
Returns (deduped_bin, remap, num_unique) where remap[original_idx] = canonical_idx.
|
||||
"""
|
||||
seen = {} # pattern_bytes -> canonical_index
|
||||
remap = {} # original_index -> canonical_index
|
||||
canonical = []
|
||||
for i in range(num_pats):
|
||||
pat = pat_bin[i * PATTERN_BYTES : (i + 1) * PATTERN_BYTES]
|
||||
if pat in seen:
|
||||
remap[i] = seen[pat]
|
||||
else:
|
||||
ci = len(canonical)
|
||||
seen[pat] = ci
|
||||
remap[i] = ci
|
||||
canonical.append(pat)
|
||||
return b''.join(canonical), remap, len(canonical)
|
||||
|
||||
|
||||
def _encode_cue(patterns12: list, instruction: int) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
|
||||
# patterns12: list of up to NUM_VOICES 12-bit values (0xFFF = disabled)
|
||||
pats = list(patterns12) + [0xFFF] * NUM_VOICES
|
||||
pats = pats[:NUM_VOICES]
|
||||
entry = bytearray(CUE_SIZE)
|
||||
for i in range(10): # 10 bytes: 2 voices per byte
|
||||
v0, v1 = pats[i*2], pats[i*2+1]
|
||||
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles
|
||||
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles
|
||||
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles
|
||||
entry[30] = instruction & 0xFF
|
||||
return entry
|
||||
|
||||
|
||||
def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
|
||||
pat_remap: dict = None) -> bytes:
|
||||
"""Build the 1024×32-byte cue sheet with 12-bit packed pattern numbers."""
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
# Fill entire sheet with the "all disabled" cue (patterns=0xFFF, instr=0)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE : c*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0)
|
||||
|
||||
cue_idx = 0
|
||||
for order in order_list:
|
||||
if order == S3M_ORDER_END or cue_idx >= NUM_CUES:
|
||||
break
|
||||
if order == S3M_ORDER_SKIP:
|
||||
cue_idx += 1
|
||||
continue
|
||||
orig = [order * num_channels + v for v in range(num_channels)]
|
||||
pats = [pat_remap[p] if pat_remap else p for p in orig]
|
||||
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue(pats, 0)
|
||||
cue_idx += 1
|
||||
|
||||
# Halt at end
|
||||
if cue_idx < NUM_CUES:
|
||||
sheet[cue_idx*CUE_SIZE : cue_idx*CUE_SIZE+CUE_SIZE] = _encode_cue([], 0x01)
|
||||
|
||||
return bytes(sheet)
|
||||
|
||||
|
||||
def find_initial_bpm_speed(patterns: list, order_list: list,
|
||||
default_speed: int, default_tempo: int) -> tuple:
|
||||
"""Scan first pattern in order for Axx/Txx in row 0 of any channel."""
|
||||
speed = default_speed or 6
|
||||
tempo = default_tempo or 125
|
||||
for order in order_list:
|
||||
if order >= S3M_ORDER_END:
|
||||
break
|
||||
if order >= len(patterns):
|
||||
continue
|
||||
grid = patterns[order]
|
||||
for ch_rows in grid:
|
||||
row = ch_rows[0]
|
||||
if row.effect == EFF_A and row.effect_arg > 0:
|
||||
speed = row.effect_arg
|
||||
if row.effect == EFF_T and row.effect_arg > 0:
|
||||
tempo = row.effect_arg
|
||||
break
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
C = len(active_channels)
|
||||
P = len(patterns)
|
||||
|
||||
if P * C > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
||||
f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute "
|
||||
f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}."
|
||||
)
|
||||
|
||||
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
|
||||
|
||||
# Build sample+instrument bin
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
# Compress
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# Initial BPM / speed
|
||||
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Song offset = header(32) + compressed + song_table(8)
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
num_taud_pats = P * C
|
||||
|
||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(2)+sig(16)
|
||||
sig = (SIGNATURE + b' ' * 16)[:16]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||
vprint(" building pattern bin…")
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
|
||||
pat_bin = bytearray()
|
||||
for pi in range(P):
|
||||
grid = patterns[pi]
|
||||
for vi, ch in enumerate(active_channels):
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides)
|
||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||
|
||||
# Deduplicate identical patterns
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = num_taud_pats
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# Song table row (16 bytes): offset(4)+voices(1)+patsLo(1)+patsHi(1)+bpm(1)+tick(1)+pad(7)
|
||||
# Built after dedup so num_taud_pats reflects the unique count.
|
||||
num_taud_pats_lo = num_taud_pats & 0xFF
|
||||
num_taud_pats_hi = (num_taud_pats >> 8) & 0xFF
|
||||
song_table = struct.pack('<IBBBBB',
|
||||
song_offset,
|
||||
C,
|
||||
num_taud_pats_lo,
|
||||
num_taud_pats_hi,
|
||||
bpm_stored,
|
||||
speed,
|
||||
) + b'\x00' * 7
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
|
||||
# Cue sheet (using remapped pattern indices)
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
|
||||
return header + compressed + song_table + bytes(pat_bin) + cue_sheet
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
global VERBOSE
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('input', help='Input .s3m file')
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
args = ap.parse_args()
|
||||
|
||||
VERBOSE = args.verbose
|
||||
|
||||
with open(args.input, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
|
||||
h = parse_s3m(data)
|
||||
vprint(f" title: '{h.title}'")
|
||||
vprint(f" orders={h.order_count}, instruments={h.inst_count}, patterns={h.pat_count}")
|
||||
|
||||
instruments = parse_instruments(data, h)
|
||||
patterns = parse_patterns(data, h)
|
||||
|
||||
taud = assemble_taud(h, instruments, patterns)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
print(f"wrote {len(taud)} bytes to '{args.output}'")
|
||||
if VERBOSE:
|
||||
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
107
terranmon.txt
107
terranmon.txt
@@ -1985,25 +1985,25 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
|
||||
Memory Space
|
||||
|
||||
0..114687 RW: Sample bin
|
||||
114688..131071 RW: Instrument bin (256 instruments, 64 bytes each)
|
||||
131072..196607 RW: Play data 1
|
||||
196608..262143 RW: Play data 2
|
||||
262144..327679 RW: TAD Input Buffer
|
||||
327680..393215 RW: TAD Decode Output
|
||||
0..770047 RW: Sample bin (752k)
|
||||
770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; 16k)
|
||||
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
983040..1048575 RW: TAD Decode Output (64k)
|
||||
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
|
||||
|
||||
Instrument bin: Registry for 256 instruments, formatted as:
|
||||
Uint16 Sample Pointer
|
||||
Uint16 Sample length
|
||||
Uint16 Sampling rate at C3
|
||||
Uint16 Sampling rate at C3 (note number 0x4000)
|
||||
Uint16 Play Start (usually 0 but not always)
|
||||
Uint16 Loop Start (can be smaller than Play Start)
|
||||
Uint16 Loop End
|
||||
Bit32 Flags
|
||||
0b hhhh 00pp
|
||||
h: sample pointer high bit (only the lowest bit is used, keep upper 3 bits unset)
|
||||
h: sample pointer high bit
|
||||
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
|
||||
Bit16x24 Volume envelopes
|
||||
Byte 1: Volume
|
||||
@@ -2012,7 +2012,7 @@ Instrument bin: Registry for 256 instruments, formatted as:
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
rr||NOTE|Ins|E.Vol|E.Pan|EE.ff|
|
||||
63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 256 patterns on 128 kB block)
|
||||
63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 128 patterns on 64 kB bank, 32 banks available (pattern 0xFFF -- bank 31, pattern 127 is a sentinel value for no-pattern))
|
||||
|
||||
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value.
|
||||
|
||||
@@ -2025,13 +2025,19 @@ note 0x0000: key-off
|
||||
|
||||
Sound Adapter MMIO
|
||||
|
||||
0..1 RW: Play head #0 position (how many samples has been queued)
|
||||
0..1 RW: Play head #0 position
|
||||
PCM mode: number of buffers uploaded and received by the adapter (writing does nothing)
|
||||
Tracker mode: current position in the cuesheet (writing changes current position in the cuesheet and resets pattern cursor back to zero)
|
||||
2..3 RW: Play head #0 length param
|
||||
PCM mode: length of the samples to upload to the speaker
|
||||
Tracker mode:
|
||||
Byte 2: Play data 1 bank
|
||||
Byte 3: Play data 2 bank
|
||||
4 RW: Play head #0 master volume
|
||||
5 RW: Play head #0 master pan
|
||||
6..9 RW: Play head #0 flags
|
||||
6..9 RW: Play head #0 flags (see below)
|
||||
|
||||
10..11 RW:Play head #1 position (how many samples has been queued)
|
||||
10..11 RW:Play head #1 position
|
||||
12..13 RW:Play head #1 length param
|
||||
14 RW: Play head #1 master volume
|
||||
15 RW: Play head #1 master pan
|
||||
@@ -2043,17 +2049,16 @@ Sound Adapter MMIO
|
||||
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
||||
Write 1 to decode the frame as MP2
|
||||
|
||||
When called with byte 17, initialisation will precede before the decoding
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
|
||||
|
||||
41 RO: MP2 Decoder Status
|
||||
Non-zero value indicates the decoder is busy
|
||||
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
42 WO: TAD Decoder Control
|
||||
Write 1 to decode TAD data
|
||||
43 RW: TAD Quality
|
||||
Must be set to appropriate value before decoding
|
||||
44 RW: TAD Decoder Status
|
||||
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
45 RW: Select PCM Bin for playhead (writing causes side effects)
|
||||
|
||||
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
|
||||
@@ -2065,14 +2070,6 @@ Sound Hardware Info
|
||||
- Bit depth: 8 bits/sample, unsigned
|
||||
- Always operate in stereo (mono samples must be expanded to stereo before uploading)
|
||||
|
||||
Play Head Position
|
||||
- Tracker mode: Cuesheet Counter
|
||||
- PCM mode: Number of buffers uploaded and received by the adapter
|
||||
|
||||
Length Param
|
||||
PCM Mode: length of the samples to upload to the speaker
|
||||
Tracker mode: unused
|
||||
|
||||
Play Head Flags
|
||||
Byte 1
|
||||
- 0b mrqp ssss
|
||||
@@ -2117,18 +2114,20 @@ Play Head Flags
|
||||
If the queue is full, any more uploads will be silently discarded.
|
||||
|
||||
|
||||
32768..65535 RW: Cue Sheet (2048 cues)
|
||||
Byte 1..15: pattern number for voice 1..15
|
||||
Byte 16: instruction
|
||||
1 xxxxxxx - Go back (128, 1-127) patterns to form a loop
|
||||
01 xxxxxx -
|
||||
001 xxxxx -
|
||||
0001 xxxx - Skip (16, 1-15) patterns
|
||||
00001 xxx -
|
||||
000001 xx -
|
||||
0000001 x -
|
||||
0000000 1 -
|
||||
0000000 0 - No operation
|
||||
32768..65535 RW: Cue Sheet (1024 cues)
|
||||
Byte 1..10: Pattern number low nybble for voice 1..20
|
||||
Byte 11..20: Pattern number middle nybble for voice 1..20
|
||||
Byte 21..30: Pattern number high nybble for voice 1..20
|
||||
To recap:
|
||||
Byte 1..10: 0b loV1 loV2, 0b loV3 loV4, 0b loV5 loV6, ... 0b loV19 loV20
|
||||
Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20
|
||||
Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20
|
||||
Byte 31..32: instruction
|
||||
1000xxxx yyyyyyyy - Go back 0bxxxxyyyyyyyy patterns
|
||||
1001xxxx yyyyyyyy - Skip forward 0bxxxxyyyyyyyy patterns
|
||||
1111xxxx yyyyyyyy - Go to absolute pattern number 0bxxxxyyyyyyyy
|
||||
00000001 - Halt
|
||||
00000000 - No operation
|
||||
|
||||
65536..131071 RW: PCM Sample buffer
|
||||
|
||||
@@ -2168,7 +2167,43 @@ Table of 3.5 Minifloat values (CSV)
|
||||
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
|
||||
LSB
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Taud serialisation format
|
||||
|
||||
This is a file format for Taud tracker data.
|
||||
|
||||
# File Structure
|
||||
\x1F T S V M a u d
|
||||
[HEADER]
|
||||
[SAMPLE+INSTRUMENT BIN IMAGE (GZip or Zstd compressed. Read 4-byte magic to determine)]
|
||||
[SONG TABLE]
|
||||
[PATTERN BIN for SONG 0]
|
||||
[CUE SHEET for SONG 0]
|
||||
[PATTERN BIN for SONG 1]
|
||||
[CUE SHEET for SONG 1]
|
||||
[PATTERN BIN for SONG 2]
|
||||
[CUE SHEET for SONG 2]
|
||||
...
|
||||
|
||||
## Header
|
||||
Byte[8] Magic
|
||||
Uint8 Format version (always 1)
|
||||
Uint8 Number of songs in SONG TABLE
|
||||
Uint32 Compressed size of SAMPLE+INST section (used to calculate offset to SONG TABLE)
|
||||
Uint16 Reserved for future versions
|
||||
Byte[16]Tracker/Converter signature
|
||||
|
||||
## SONG TABLE
|
||||
Rows of 16 bytes:
|
||||
Uint32 Song offset
|
||||
Uint8 Number of voices
|
||||
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
|
||||
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=280)
|
||||
Uint8 Initial Tickrate(0 is invalid)
|
||||
Byte[7] Reserved for future versions
|
||||
|
||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun setCuePosition(playhead: Int, pos: Int) {
|
||||
getPlayhead(playhead)?.let { ph ->
|
||||
ph.position = pos and 2047
|
||||
ph.position = pos and 1023
|
||||
ph.trackerState?.cuePos = ph.position
|
||||
}
|
||||
}
|
||||
@@ -96,17 +96,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-255). */
|
||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
||||
fun uploadPattern(slot: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.playdata?.get(slot and 0xFF)?.let { pat ->
|
||||
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
||||
for (i in 0 until minOf(512, bytes.size)) pat[i / 8].setByte(i % 8, bytes[i] and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload 16 bytes defining cue entry `idx` (0-2047): bytes 0-14 = pattern numbers for voices 0-14, byte 15 = instruction. */
|
||||
/** Upload 32 bytes defining cue entry `idx` (0-1023): packed 12-bit pattern numbers for 20 voices + instruction. */
|
||||
fun uploadCue(idx: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.cueSheet?.get(idx and 0x7FF)?.let { cue ->
|
||||
for (i in 0 until minOf(16, bytes.size)) cue.write(i, bytes[i] and 0xFF)
|
||||
getFirstSnd()?.cueSheet?.get(idx and 0x3FF)?.let { cue ->
|
||||
for (i in 0 until minOf(32, bytes.size)) cue.write(i, bytes[i] and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,13 @@ private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable {
|
||||
|
||||
Thread.sleep(1)
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
System.err.println("[AudioAdapter] RenderRunnable crashed: $e")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,11 +124,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val TRACKER_C3 = 0x4000
|
||||
}
|
||||
|
||||
internal val sampleBin = UnsafeHelper.allocate(114687L, this)
|
||||
internal val sampleBin = UnsafeHelper.allocate(770048L, this)
|
||||
internal val instruments = Array(256) { TaudInst() }
|
||||
internal val playdata = Array(256) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
|
||||
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
|
||||
internal val playheads: Array<Playhead>
|
||||
internal val cueSheet = Array(2048) { PlayCue() }
|
||||
internal val cueSheet = Array(1024) { PlayCue() }
|
||||
internal val pcmBin = arrayOf(
|
||||
UnsafeHelper.allocate(65536L, this),
|
||||
UnsafeHelper.allocate(65536L, this),
|
||||
@@ -292,12 +296,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
override fun peek(addr: Long): Byte {
|
||||
return when (val adi = addr.toInt()) {
|
||||
in 0..114687 -> sampleBin[addr]
|
||||
in 114688..131071 -> (adi - 114688).let { instruments[it / 64].getByte(it % 64) }
|
||||
in 131072..262143 -> (adi - 131072).let { playdata[it / (8*64)][(it / 8) % 64].getByte(it % 8) }
|
||||
in 262144..327679 -> tadInputBin[addr - 262144] // TAD input buffer (65536 bytes)
|
||||
in 327680..393215 -> tadDecodedBin[addr - 327680] // TAD decoded output (65536 bytes)
|
||||
else -> peek(addr % 393216)
|
||||
in 0..770047 -> sampleBin[addr]
|
||||
in 770048..786431 -> (adi - 770048).let { instruments[it / 64].getByte(it % 64) }
|
||||
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
|
||||
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
|
||||
in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes)
|
||||
in 983040..1048575 -> tadDecodedBin[addr - 983040] // TAD decoded output (65536 bytes)
|
||||
else -> peek(addr % 1048576)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +310,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
val adi = addr.toInt()
|
||||
val bi = byte.toUint()
|
||||
when (adi) {
|
||||
in 0..114687 -> { sampleBin[addr] = byte }
|
||||
in 114688..131071 -> (adi - 114688).let { instruments[it / 64].setByte(it % 64, bi) }
|
||||
in 131072..262143 -> (adi - 131072).let { playdata[it / (8*64)][(it / 8) % 64].setByte(it % 8, bi) }
|
||||
in 262144..327679 -> tadInputBin[addr - 262144] = byte // TAD input buffer
|
||||
in 327680..393215 -> tadDecodedBin[addr - 327680] = byte // TAD decoded output
|
||||
in 0..770047 -> { sampleBin[addr] = byte }
|
||||
in 770048..786431 -> (adi - 770048).let { instruments[it / 64].setByte(it % 64, bi) }
|
||||
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
|
||||
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
|
||||
in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer
|
||||
in 983040..1048575 -> tadDecodedBin[addr - 983040] = byte // TAD decoded output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +336,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
in 2368..4095 -> mediaFrameBin[addr - 2368]
|
||||
in 4096..4097 -> 0
|
||||
in 32768..65535 -> (adi - 32768).let {
|
||||
cueSheet[it / 16].read(it % 16)
|
||||
cueSheet[it / 32].read(it % 32)
|
||||
}
|
||||
in 65536..131071 -> pcmBin[selectedPcmBin][addr - 65536]
|
||||
else -> {
|
||||
@@ -364,7 +370,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
in 64..2367 -> { mediaDecodedBin[addr - 64] = byte }
|
||||
in 2368..4095 -> { mediaFrameBin[addr - 2368] = byte }
|
||||
in 32768..65535 -> { (adi - 32768).let {
|
||||
cueSheet[it / 16].write(it % 16, bi)
|
||||
cueSheet[it / 32].write(it % 32, bi)
|
||||
} }
|
||||
in 65536..131071 -> { pcmBin[selectedPcmBin][addr - 65536] = byte }
|
||||
}
|
||||
@@ -398,7 +404,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private var mp2Context = mp2Env.initialise()
|
||||
|
||||
private fun decodeMp2() {
|
||||
val periMmioBase = vm.findPeriSlotNum(this)!! * -131072 - 1L
|
||||
val periMmioBase = vm.findPeriSlotNum(this)!! * -786432 - 1L
|
||||
mp2Env.decodeFrameU8(mp2Context, periMmioBase - 2368, true, periMmioBase - 64)
|
||||
}
|
||||
|
||||
@@ -1108,11 +1114,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
private fun fetchTrackerSample(voice: Voice, inst: TaudInst): Double {
|
||||
val basePtr = inst.samplePtr and 0x1FFFF
|
||||
val basePtr = inst.samplePtr
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
val loopStart = inst.sampleLoopStart.toDouble()
|
||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||
val binMax = 114686 // sampleBin is 114687 bytes (0..114686)
|
||||
val binMax = 770047 // sampleBin is 770048 bytes (0..770047)
|
||||
|
||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
|
||||
@@ -1138,8 +1144,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
private fun applyTrackerRow(ts: TrackerState, playhead: Playhead) {
|
||||
val cue = cueSheet[ts.cuePos]
|
||||
for (vi in 0..14) {
|
||||
val patIdx = cue.patterns[vi].coerceIn(0, 255)
|
||||
for (vi in 0..19) {
|
||||
val patNum = cue.patterns[vi]
|
||||
if (patNum == 0xFFF) continue
|
||||
val patIdx = patNum.coerceIn(0, 4095)
|
||||
val row = playdata[patIdx][ts.rowIndex]
|
||||
val voice = ts.voices[vi]
|
||||
|
||||
@@ -1195,10 +1203,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
|
||||
private fun advanceTrackerCue(ts: TrackerState, playhead: Playhead) {
|
||||
val instr = cueSheet[ts.cuePos].instruction
|
||||
if (instr is PlayInstHalt) { playhead.isPlaying = false; return }
|
||||
ts.cuePos = when (instr) {
|
||||
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
|
||||
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(2047)
|
||||
else -> (ts.cuePos + 1).coerceAtMost(2047)
|
||||
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
|
||||
else -> (ts.cuePos + 1).coerceAtMost(1023)
|
||||
}
|
||||
playhead.position = ts.cuePos
|
||||
}
|
||||
@@ -1250,28 +1259,60 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
internal data class PlayCue(
|
||||
val patterns: IntArray = IntArray(15) { it },
|
||||
val patterns: IntArray = IntArray(20) { 0xFFF },
|
||||
var instruction: PlayInstruction = PlayInstNop
|
||||
) {
|
||||
// Cue layout (32 bytes, 20 voices, 12-bit pattern numbers):
|
||||
// bytes 0-9: packed low nybbles (byte i => voice i*2 in hi, voice i*2+1 in lo)
|
||||
// bytes 10-19: packed mid nybbles (same packing)
|
||||
// bytes 20-29: packed high nybbles (same packing)
|
||||
// byte 30: instruction
|
||||
// byte 31: unused
|
||||
fun write(index: Int, byte: Int) = when (index) {
|
||||
in 0..14 -> { patterns[index] = byte }
|
||||
15 -> { instruction = when {
|
||||
in 0..9 -> {
|
||||
val b = index * 2
|
||||
patterns[b] = (patterns[b] and 0xFF0) or ((byte ushr 4) and 0xF)
|
||||
patterns[b + 1] = (patterns[b + 1] and 0xFF0) or (byte and 0xF)
|
||||
}
|
||||
in 10..19 -> {
|
||||
val b = (index - 10) * 2
|
||||
patterns[b] = (patterns[b] and 0xF0F) or (((byte ushr 4) and 0xF) shl 4)
|
||||
patterns[b + 1] = (patterns[b + 1] and 0xF0F) or ((byte and 0xF) shl 4)
|
||||
}
|
||||
in 20..29 -> {
|
||||
val b = (index - 20) * 2
|
||||
patterns[b] = (patterns[b] and 0x0FF) or (((byte ushr 4) and 0xF) shl 8)
|
||||
patterns[b + 1] = (patterns[b + 1] and 0x0FF) or ((byte and 0xF) shl 8)
|
||||
}
|
||||
30 -> { instruction = when {
|
||||
byte >= 128 -> PlayInstGoBack(byte and 127)
|
||||
byte in 16..31 -> PlayInstSkip(byte and 15)
|
||||
byte == 1 -> PlayInstHalt
|
||||
else -> PlayInstNop
|
||||
} }
|
||||
31 -> {}
|
||||
else -> throw InternalError("Bad offset $index")
|
||||
}
|
||||
fun read(index: Int): Byte = when(index) {
|
||||
in 0..14 -> patterns[index].toByte()
|
||||
15 -> {
|
||||
when (instruction) {
|
||||
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte()
|
||||
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
|
||||
is PlayInstNop -> 0
|
||||
else -> throw InternalError("Bad instruction ${instruction.javaClass.simpleName}")
|
||||
}
|
||||
fun read(index: Int): Byte = when (index) {
|
||||
in 0..9 -> {
|
||||
val b = index * 2
|
||||
(((patterns[b] and 0xF) shl 4) or (patterns[b + 1] and 0xF)).toByte()
|
||||
}
|
||||
in 10..19 -> {
|
||||
val b = (index - 10) * 2
|
||||
((((patterns[b] ushr 4) and 0xF) shl 4) or ((patterns[b + 1] ushr 4) and 0xF)).toByte()
|
||||
}
|
||||
in 20..29 -> {
|
||||
val b = (index - 20) * 2
|
||||
((((patterns[b] ushr 8) and 0xF) shl 4) or ((patterns[b + 1] ushr 8) and 0xF)).toByte()
|
||||
}
|
||||
30 -> when (instruction) {
|
||||
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte()
|
||||
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
|
||||
is PlayInstHalt -> 1
|
||||
else -> 0
|
||||
}
|
||||
31 -> 0
|
||||
else -> throw InternalError("Bad offset $index")
|
||||
}
|
||||
}
|
||||
@@ -1279,6 +1320,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
internal open class PlayInstruction(val arg: Int)
|
||||
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
|
||||
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
|
||||
internal object PlayInstHalt : PlayInstruction(0)
|
||||
internal object PlayInstNop : PlayInstruction(0)
|
||||
|
||||
class Voice {
|
||||
@@ -1304,7 +1346,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var tickInRow = 0
|
||||
var samplesIntoTick = 0.0
|
||||
var firstRow = true
|
||||
val voices = Array(15) { Voice() }
|
||||
val voices = Array(20) { Voice() }
|
||||
}
|
||||
|
||||
class Playhead(
|
||||
@@ -1316,9 +1358,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var masterVolume: Int = 0,
|
||||
var masterPan: Int = 128,
|
||||
// var samplingRateMult: ThreeFiveMiniUfloat = ThreeFiveMiniUfloat(32),
|
||||
var bpm: Int = 120, // "stored" as 96
|
||||
var bpm: Int = 120,
|
||||
var tickRate: Int = 6,
|
||||
var pcmUpload: Boolean = false,
|
||||
var patBank1: Int = 0,
|
||||
var patBank2: Int = 0,
|
||||
|
||||
var pcmQueue: Queue<ByteArray> = Queue<ByteArray>(),
|
||||
var pcmQueueSizeIndex: Int = 0,
|
||||
@@ -1350,8 +1394,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
fun read(index: Int): Byte = when (index) {
|
||||
0 -> position.toByte()
|
||||
1 -> position.ushr(8).toByte()
|
||||
2 -> pcmUploadLength.toByte()
|
||||
3 -> pcmUploadLength.ushr(8).toByte()
|
||||
2 -> if (isPcmMode) pcmUploadLength.toByte() else patBank1.toByte()
|
||||
3 -> if (isPcmMode) pcmUploadLength.ushr(8).toByte() else patBank2.toByte()
|
||||
4 -> masterVolume.toByte()
|
||||
5 -> masterPan.toByte()
|
||||
6 -> (isPcmMode.toInt(7) or isPlaying.toInt(4) or pcmQueueSizeIndex.and(15)).toByte()
|
||||
@@ -1366,8 +1410,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
when (index) {
|
||||
0 -> if (!isPcmMode) { position = (position and 0xff00) or byte; trackerState?.cuePos = position } else {}
|
||||
1 -> if (!isPcmMode) { position = (position and 0x00ff) or (byte shl 8); trackerState?.cuePos = position } else {}
|
||||
2 -> { pcmUploadLength = (pcmUploadLength and 0xff00) or byte }
|
||||
3 -> { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) }
|
||||
2 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0xff00) or byte } else { patBank1 = byte and 0x1F }
|
||||
3 -> if (isPcmMode) { pcmUploadLength = (pcmUploadLength and 0x00ff) or (byte shl 8) } else { patBank2 = byte and 0x1F }
|
||||
4 -> {
|
||||
masterVolume = byte
|
||||
audioDevice.setVolume(masterVolume / 255f)
|
||||
@@ -1504,7 +1548,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
fun setByte(offset: Int, byte: Int) = when (offset) {
|
||||
0 -> { samplePtr = (samplePtr and 0x1ff00) or byte }
|
||||
0 -> { samplePtr = (samplePtr and 0xfff00) or byte }
|
||||
1 -> { samplePtr = (samplePtr and 0x000ff) or (byte shl 8) }
|
||||
|
||||
2 -> { sampleLength = (sampleLength and 0xff00) or byte }
|
||||
|
||||
@@ -85,6 +85,107 @@ object TinyAlphNum : BitmapFont() {
|
||||
|
||||
|
||||
|
||||
private fun isColourCodeHigh(c: Char) = c.toInt() in 0b110110_1111000000..0b110110_1111111111
|
||||
private fun isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111
|
||||
|
||||
private fun getColour(charHigh: Char, charLow: Char): Color { // input: 0x10ARGB, out: RGBA8888
|
||||
val codePoint = Character.toCodePoint(charHigh, charLow)
|
||||
|
||||
if (colourBuffer.containsKey(codePoint))
|
||||
return colourBuffer[codePoint]!!
|
||||
|
||||
val a = codePoint.and(0xF000).ushr(12)
|
||||
val r = codePoint.and(0x0F00).ushr(8)
|
||||
val g = codePoint.and(0x00F0).ushr(4)
|
||||
val b = codePoint.and(0x000F)
|
||||
|
||||
val col = Color(r.shl(28) or r.shl(24) or g.shl(20) or g.shl(16) or b.shl(12) or b.shl(8) or a.shl(4) or a)
|
||||
|
||||
|
||||
colourBuffer[codePoint] = col
|
||||
return col
|
||||
}
|
||||
|
||||
private val colourBuffer = HashMap<Int, Color>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Created by minjaesong on 2026-04-19.
|
||||
*/
|
||||
object PatternView : BitmapFont() {
|
||||
|
||||
internal val W = 5
|
||||
internal val H = 6
|
||||
|
||||
internal val fontSheet = TextureRegionPack(Gdx.files.internal("net/torvald/terrarum/imagefont/tiny.tga"), W, H)
|
||||
|
||||
|
||||
init {
|
||||
setOwnsTexture(true)
|
||||
setUseIntegerPositions(true)
|
||||
}
|
||||
|
||||
fun getWidth(str: String): Int {
|
||||
var l = 0
|
||||
for (char in str) {
|
||||
if (!isColourCodeHigh(char) && !isColourCodeLow(char)) {
|
||||
l += 1
|
||||
}
|
||||
}
|
||||
return W * l
|
||||
}
|
||||
|
||||
lateinit var colMain: Color
|
||||
lateinit var colShadow: Color
|
||||
|
||||
override fun draw(batch: Batch, text: CharSequence, x: Float, y: Float): GlyphLayout? {
|
||||
val originalColour = batch.color.cpy()
|
||||
colMain = batch.color.cpy()
|
||||
colShadow = colMain.cpy().mul(0.5f, 0.5f, 0.5f, 1f)
|
||||
|
||||
val x = x.roundToInt().toFloat()
|
||||
val y = y.roundToInt().toFloat()
|
||||
|
||||
var charsPrinted = 0
|
||||
text.forEachIndexed { index, c ->
|
||||
if (isColourCodeHigh(c)) {
|
||||
val cchigh = c
|
||||
val cclow = text[index + 1]
|
||||
val colour = getColour(cchigh, cclow)
|
||||
|
||||
colMain = colour
|
||||
colShadow = colMain.cpy().mul(0.5f, 0.5f, 0.5f, 1f)
|
||||
}
|
||||
else if (c in 0.toChar()..255.toChar()) {
|
||||
batch.color = colShadow
|
||||
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W + 1, y)
|
||||
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W, y + 1)
|
||||
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W + 1, y + 1)
|
||||
|
||||
|
||||
batch.color = colMain
|
||||
batch.draw(fontSheet.get(c.toInt() % 16, c.toInt() / 16), x + charsPrinted * W, y)
|
||||
|
||||
charsPrinted += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
batch.color = originalColour
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun drawRalign(batch: Batch, text: CharSequence, x: Float, y: Float): GlyphLayout? {
|
||||
return draw(batch, text, x - W*text.length, y)
|
||||
}
|
||||
|
||||
override fun getLineHeight() = H.toFloat()
|
||||
override fun getCapHeight() = getLineHeight()
|
||||
override fun getXHeight() = getLineHeight()
|
||||
|
||||
|
||||
|
||||
private fun isColourCodeHigh(c: Char) = c.toInt() in 0b110110_1111000000..0b110110_1111111111
|
||||
private fun isColourCodeLow(c: Char) = c.toInt() in 0b110111_0000000000..0b110111_1111111111
|
||||
|
||||
|
||||
BIN
tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga
LFS
Normal file
BIN
tsvm_executable/src/net/torvald/terrarum/imagefont/tiny.tga
LFS
Normal file
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
package net.torvald.tsvm
|
||||
|
||||
import com.badlogic.gdx.Audio
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Input.Buttons
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
import net.torvald.reflection.extortField
|
||||
@@ -9,8 +11,8 @@ import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE3
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.TINY
|
||||
import net.torvald.tsvm.peripheral.AudioAdapter
|
||||
import java.lang.Math.pow
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
@@ -21,19 +23,70 @@ import kotlin.math.roundToInt
|
||||
*/
|
||||
class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) {
|
||||
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
|
||||
private val scopeMode = IntArray(4)
|
||||
|
||||
override fun show() {
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
}
|
||||
|
||||
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
|
||||
|
||||
override fun update() {
|
||||
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||
if (!guiClickLatched[Buttons.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) % 4
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
guiClickLatched[Buttons.LEFT] = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val COL_SOUNDSCOPE_BACK = Color(0x081c08ff.toInt())
|
||||
private val COL_SOUNDSCOPE_FORE = Color(0x80f782ff.toInt())
|
||||
private val COL_TRACKER_ROW = Color(0x103010ff.toInt())
|
||||
private val STR_PLAY = "\u00D2\u00D3"
|
||||
|
||||
// Pattern field colours (loosely following MilkyTracker scheme)
|
||||
private val COL_NOTE = Color(1f, 1f, 1f, 1f) // white
|
||||
private val COL_INST = Color(0x6BB5FFff.toInt()) // sky blue
|
||||
private val COL_VOL = Color(0x80FF50ff.toInt()) // lime
|
||||
private val COL_PAN = Color(0xFFC040ff.toInt()) // amber
|
||||
private val COL_EFF = Color(0xFF50FFff.toInt()) // magenta
|
||||
private val COL_EFFARG = Color(0xFFAF7Fff.toInt()) // apricot
|
||||
|
||||
// Voice colours for cue-sheet view — 10-colour palette cycling across 20 voices
|
||||
private val COL_VOICE_PALETTE = arrayOf(
|
||||
Color(0xC0C0C0ff.toInt()), // 0: silver
|
||||
Color(0xFF8080ff.toInt()), // 1: salmon
|
||||
Color(0xFFBF60ff.toInt()), // 2: tangerine
|
||||
Color(0xFFFF70ff.toInt()), // 3: yellow
|
||||
Color(0x80FF80ff.toInt()), // 4: lime
|
||||
Color(0x60EEEEff.toInt()), // 5: aqua
|
||||
Color(0x80A0FFff.toInt()), // 6: periwinkle
|
||||
Color(0xD080FFff.toInt()), // 7: orchid
|
||||
Color(0xFF80C0ff.toInt()), // 8: pink
|
||||
Color(0xA0D0A0ff.toInt()), // 9: sage
|
||||
)
|
||||
|
||||
|
||||
override fun render(batch: SpriteBatch) {
|
||||
|
||||
@@ -111,7 +164,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
|
||||
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${ahead.position}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos}:${ahead.trackerState?.rowIndex?.toString(16)?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
|
||||
@@ -122,9 +175,20 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
|
||||
fun Int.u16Tos16() = if (this > 32767) this - 65536 else this
|
||||
|
||||
private fun readCuePat12(audio: AudioAdapter, ci: Int, vi: Int): Int {
|
||||
val byteGroup = vi / 2
|
||||
val shift = if (vi % 2 == 0) 4 else 0
|
||||
val lo = (audio.mmio_read(32768L + ci * 32 + byteGroup ).toUint() ushr shift) and 0xF
|
||||
val mid = (audio.mmio_read(32768L + ci * 32 + 10 + byteGroup).toUint() ushr shift) and 0xF
|
||||
val hi = (audio.mmio_read(32768L + ci * 32 + 20 + byteGroup).toUint() ushr shift) and 0xF
|
||||
return (hi shl 8) or (mid shl 4) or lo
|
||||
}
|
||||
|
||||
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
|
||||
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(5,8,16)
|
||||
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
|
||||
val gdxadev = ahead.audioDevice
|
||||
val bytes = gdxadev.extortField<ByteArray>("bytes")
|
||||
@@ -175,7 +239,191 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
|
||||
}
|
||||
else {
|
||||
// Tracker pattern visualiser.
|
||||
// Modes: 0=detailed pattern, 1=abridged (stub), 2=super-abridged (stub), 3=cuesheet detail
|
||||
val ts = ahead.trackerState
|
||||
if (ts == null) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
FONT.draw(batch, "No tracker state", x, y + 4)
|
||||
} else {
|
||||
val cuePos = ts.cuePos
|
||||
val rowIdx = ts.rowIndex
|
||||
val ROWS = 17
|
||||
val PTN_MAX_ROWS = 63
|
||||
|
||||
when (scopeMode[index]) {
|
||||
|
||||
// ── Mode 3: Cue-sheet detail ─────────────────────────────────────
|
||||
3 -> {
|
||||
// Layout per row: >NNN|p00p01…p19|INS
|
||||
// Voice pattern numbers are colour-coded; no spaces (colour provides separation).
|
||||
val cueFirst = (cuePos - ROWS / 2).coerceAtLeast(0).coerceAtMost(1023 - ROWS + 1)
|
||||
for (r in 0 until ROWS) {
|
||||
val ci = cueFirst + r
|
||||
if (ci > 1023) break
|
||||
val here = ci == cuePos
|
||||
val ry = y + 4 + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(x, ry, 512, TINY.H)
|
||||
}
|
||||
|
||||
var cx = x
|
||||
// cursor + cue number
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, "${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}|", cx, ry)
|
||||
cx += 5 * TINY.W
|
||||
|
||||
// voice pattern numbers
|
||||
for (vi in 0 until 20) {
|
||||
if (vi > 0) { cx += TINY.W }
|
||||
val pat = readCuePat12(audio, ci, vi)
|
||||
val patStr = if (pat == 0xFFF) "---"
|
||||
else pat.toString(16).padStart(3, '0').uppercase()
|
||||
batch.color = if (here) Color.WHITE else COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
|
||||
TINY.draw(batch, patStr, cx, ry)
|
||||
cx += 3 * TINY.W
|
||||
}
|
||||
|
||||
// instruction
|
||||
val instrByte = audio.mmio_read(32768L + ci * 32 + 30).toUint()
|
||||
val instrStr3 = when {
|
||||
instrByte == 0x00 -> " " // no-op
|
||||
instrByte == 0x01 -> "HALT"
|
||||
instrByte and 0x80 != 0 -> "BACK ${(instrByte and 0x7F).toString(16).padStart(2, '0').uppercase()}"
|
||||
instrByte and 0xF0 == 0x10 -> "FWRD ${(instrByte and 0x0F).toString(16).uppercase()}"
|
||||
else -> "?${instrByte.toString(16).padStart(2, '0').uppercase()}"
|
||||
}
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, "|$instrStr3", cx, ry)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 0: Detailed pattern with colour-coded fields ────────────
|
||||
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
||||
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
|
||||
0, 1, 2 -> {
|
||||
val cueW = 4 * TINY.W
|
||||
val sepW = TINY.W
|
||||
// val patX = x + cueW + sepW
|
||||
val patX = x
|
||||
val VOICES = VOX_PER_VIEW[scopeMode[index]]
|
||||
|
||||
// Abridged cue sheet (left column, 8 entries centred on current cue)
|
||||
/*val cueFirst = (cuePos - ROWS / 2).coerceAtLeast(0).coerceAtMost(1023 - ROWS + 1)
|
||||
for (r in 0 until ROWS) {
|
||||
val ci = cueFirst + r
|
||||
if (ci > 1023) break
|
||||
val here = ci == cuePos
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch,
|
||||
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
|
||||
x, y + 4 + r * TINY.H)
|
||||
}
|
||||
|
||||
// Vertical separator
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + 4 + r * TINY.H)
|
||||
*/
|
||||
|
||||
// Pattern index for each voice in current cue
|
||||
val cuePats = IntArray(VOICES) { vi -> readCuePat12(audio, cuePos, vi) }
|
||||
|
||||
// Pattern rows (right area, 8 rows centred on current row)
|
||||
// Layout: > rr NOTE in E.Vo E.Pn Eff ffff [voice1 …]
|
||||
// 1 2 4 2 4 4 2 4
|
||||
val rowFirst = (rowIdx - ROWS / 2).coerceAtLeast(0).coerceAtMost(PTN_MAX_ROWS - ROWS + 1)
|
||||
for (r in 0 until ROWS) {
|
||||
val ri = rowFirst + r
|
||||
if (ri > PTN_MAX_ROWS) break
|
||||
val here = ri == rowIdx
|
||||
val ry = y + 4 + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(patX, ry, 512 - cueW - sepW, TINY.H)
|
||||
}
|
||||
|
||||
var cx = patX
|
||||
|
||||
// cursor + row number (drawn once per row)
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, if (here) ">" else " ", cx, ry)
|
||||
cx += TINY.W
|
||||
TINY.draw(batch, ri.toString().padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
|
||||
for (vi in 0 until VOICES) {
|
||||
val pat12 = cuePats[vi]
|
||||
if (pat12 == 0xFFF) {
|
||||
// disabled voice — dimmed placeholder, same width as a live voice
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch, "(NO PATTERN DATA OR REACHED THE END OF THE SONG) ", cx, ry)
|
||||
} else {
|
||||
val localPat = pat12 and 0xFF
|
||||
val base = if (localPat < 128) 786432L + localPat * 512 + ri * 8
|
||||
else 851968L + (localPat - 128) * 512 + ri * 8
|
||||
|
||||
val noteLo = audio.peek(base + 0).toUint()
|
||||
val noteHi = audio.peek(base + 1).toUint()
|
||||
val noteVal = noteLo or (noteHi shl 8)
|
||||
val instr = audio.peek(base + 2).toUint()
|
||||
val volByte = audio.peek(base + 3).toUint()
|
||||
val panByte = audio.peek(base + 4).toUint()
|
||||
val eff = audio.peek(base + 5).toUint()
|
||||
val eaLo = audio.peek(base + 6).toUint()
|
||||
val eaHi = audio.peek(base + 7).toUint()
|
||||
|
||||
val vol = volByte and 63
|
||||
val volEff = (volByte ushr 6) and 3
|
||||
val pan = panByte and 63
|
||||
val panEff = (panByte ushr 6) and 3
|
||||
val effArg = eaLo or (eaHi shl 8)
|
||||
|
||||
val noteStr = when (noteVal) {
|
||||
0xFFFF -> "@@@@"
|
||||
0x0000 -> "===="
|
||||
0xFFFE -> "^^^^"
|
||||
else -> noteVal.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
|
||||
// note
|
||||
batch.color = if (here) Color.WHITE else COL_NOTE
|
||||
TINY.draw(batch, noteStr, cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
// instrument
|
||||
batch.color = if (here) Color.WHITE else COL_INST
|
||||
TINY.draw(batch, instr.toString(16).padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
if (scopeMode[index] == 0) {
|
||||
// volume
|
||||
batch.color = if (here) Color.WHITE else COL_VOL
|
||||
TINY.draw(batch, "$volEff.${vol.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
}
|
||||
// pan
|
||||
if (scopeMode[index] == 0) {
|
||||
batch.color = if (here) Color.WHITE else COL_PAN
|
||||
TINY.draw(batch, "$panEff.${pan.toString().padStart(2, '0')}", cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
}
|
||||
if (scopeMode[index] < 2) {
|
||||
// effect opcode
|
||||
batch.color = if (here) Color.WHITE else COL_EFF
|
||||
TINY.draw(batch, eff.toString(16).padStart(2, '0').uppercase(), cx, ry)
|
||||
cx += 2 * TINY.W
|
||||
// effect argument
|
||||
batch.color = if (here) Color.WHITE else COL_EFFARG
|
||||
TINY.draw(batch, effArg.toString(16).padStart(4, '0').uppercase(), cx, ry)
|
||||
cx += 4 * TINY.W
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,4 +432,4 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.badlogic.gdx.utils.JsonValue
|
||||
import com.badlogic.gdx.utils.JsonWriter
|
||||
import net.torvald.terrarum.DefaultGL32Shaders
|
||||
import net.torvald.terrarum.FlippingSpriteBatch
|
||||
import net.torvald.terrarum.imagefont.PatternView
|
||||
import net.torvald.terrarum.imagefont.TinyAlphNum
|
||||
import net.torvald.terrarum.utils.JsonFetcher
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||
@@ -25,10 +26,12 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa
|
||||
companion object {
|
||||
lateinit var SQTEX: Texture; private set
|
||||
lateinit var FONT: TinyAlphNum; private set
|
||||
lateinit var TINY: PatternView; private set
|
||||
}
|
||||
|
||||
override fun create() {
|
||||
FONT = TinyAlphNum
|
||||
TINY = PatternView
|
||||
SQTEX = Texture(Gdx.files.internal("net/torvald/tsvm/sq.tga"))
|
||||
executable = VMEmuExecutable(windowWidth, windowHeight, panelsX, panelsY, diskPathRoot)
|
||||
executable.create()
|
||||
@@ -54,6 +57,8 @@ class VMEmuExecutableWrapper(val windowWidth: Int, val windowHeight: Int, var pa
|
||||
// println("App Dispose")
|
||||
executable.dispose()
|
||||
SQTEX.dispose()
|
||||
FONT.dispose()
|
||||
TINY.dispose()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user