Initial commit.
18 files changed, 1884 insertions(+), 0 deletions(-)

A => .hgignore
A => LICENSE
A => Makefile
A => README.md
A => openmsx.tcl
A => package-lock.json
A => package.json
A => res/resources.json
A => src/COM.asm
A => src/Makoto.asm
A => src/Memory.asm
A => src/Neotron.asm
A => src/Player.asm
A => src/SFG.asm
A => tools/audio.js
A => tools/glass.jar
A => tools/resources.js
A => tools/utils.js
A => .hgignore +3 -0
@@ 0,0 1,3 @@ 
+^bin/
+^node_modules/
+glob:.DS_Store

          
A => LICENSE +22 -0
@@ 0,0 1,22 @@ 
+Copyright (c) 2015, Laurens Holst
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

          
A => Makefile +22 -0
@@ 0,0 1,22 @@ 
+COPY_TARGET = /Volumes/MSXDOS2
+
+all: #node_modules
+	mkdir -p bin
+	node --unhandled-rejections=strict tools/resources.js res/resources.json
+	java -jar tools/glass.jar src/COM.asm bin/gngplay.com bin/gngplay.sym
+
+node_modules: package.json
+	npm install
+	touch -c -m node_modules
+
+dist: all
+	rm -f bin/gngplay.zip
+	zip -j bin/gngplay.zip bin/gngplay.com LICENSE README.md
+
+copy: all
+	cp bin/gngplay.com $(COPY_TARGET)/gng/
+	cp bin/*.gng $(COPY_TARGET)/gng/
+	diskutil umount $(COPY_TARGET)
+
+run: all
+	openmsx -machine Panasonic_FS-A1GT -ext Yamaha_SFG-05 -diska bin/ -script openmsx.tcl

          
A => README.md +111 -0
@@ 0,0 1,111 @@ 
+Ghosts ’n Goblins music converter & player
+==========================================
+
+Copyright 2020 Laurens Holst
+
+Project information
+-------------------
+
+  * Author: Laurens Holst <laurens@grauw.nl>
+  * Source: <https://hg.sr.ht/~grauw/gngplay>
+  * License: Simplified BSD License
+
+Converts the Ghosts ’n Goblins arcade music in VGM format to a data stream
+suitable for play back on Yamaha SFG, Neotron or Makoto. The format aims to be
+both efficient for playback and small in size. Due to a compact representation
+for the most frequent event types to reduce the individual tracks are all
+below 8K and the total size is less than 64K.
+
+
+System requirements
+-------------------
+
+  * MSX, MSX2, MSX2+ or MSX turboR
+  * 64K main RAM
+  * 16K video RAM
+  * MSX-DOS
+
+If the replayer core and music data is embedded in a ROM, the RAM requirement is
+lowered to 16K and the MSX-DOS requirement is dropped.
+
+
+Converter functions
+-------------------
+
+The converter has the following functions:
+
+  * Compact data format
+  * Simple playback on three sound chips
+  * Coalescing instrument settings per channel
+  * Grouping into five event types (key, frequency, instrument, wait, loop)
+  * Clock frequency conversion
+  * Channel remapping
+  * Small wait removal
+  * Timing quantisation
+  * Trimming of leading silence
+  * Moving frequency events before key on
+
+It also outputs VGM files for the YM2151 OPM and YM2610 OPN for easy testing
+with VGMPlay and debugging with vgm2txt.
+
+
+Music data format
+-----------------
+
+The data format consists of five types of events:
+
+  * 00-7F: Key on / off event.
+           Bit 0-2: channel, bit 3-6: slots.
+  * 80-87: Note frequency event.
+           Bit 0-2: channel, byte 1-2: OPM frequency, byte 3-4: OPN frequency.
+  * 88-8F: Instrument settings.
+           Bit 0-2: channel, byte 1-25: slot parameters, byte 26: feedback / algorithm.
+  * 90:    Loop offset.
+           Bytes 1-2: offset to loop point.
+  * 91-FF: Wait.
+           n - 90H: nr. of 60Hz ticks.
+
+
+Additional notes
+----------------
+
+The arcade game music uses two OPNs clocked at 1.5 MHz. But because it sets the
+prescaler to a frequency divider of 3 instead of the usual 6, they act like OPNs
+clocked at 3 MHz with the default prescaling.
+
+The game sets the frequency after it sets key on. Due to this you sometimes get
+a wrong note at the start of a track on real hardware, depending on the initial
+values. This was probably a bug in the original game’s replayer code which did
+not get exposed during normal play. A processing step was added to put frequency
+in front of the key on commands.
+
+Track 01 is the same as Track 27, but with two channels muted. It is recommended
+to use track 27 instead of track 01.
+
+
+Usage instructions
+------------------
+
+Extract the Ghosts ’n Goblins arcade
+[VGM pack](https://vgmrips.net/packs/pack/ghosts-n-goblins-ghosts-n-goblins),
+rename the .vgz files to .vgm.gz and extract each of them again, and then put
+them in the `res/` directory. It is recommended to re-loop track 24 so that it
+becomes smaller and the loop sounds better. Use vgm_trim with parameters
+start: 0, loop: 35111, end: 364391.
+
+The `resources.json` file in the `res` directory contains a manifest of the
+files to convert and various settings.
+
+Then, on macOS and Linux execute the `make` command to convert the files and
+compile the replayer. Test by putting the MSX-DOS system files in the `bin/`
+directory and executing the `make run` command. OpenMSX 0.16.0 or later is
+required, it currently only supports the Yamaha SFG sound chip.
+
+On Windows, execute the build commands from the Makefile manually.
+
+Note that the [glass](http://www.grauw.nl/projects/glass/) assembler which is
+embedded in the project requires [Java 8](https://adoptopenjdk.net/). To check
+your Java version, invoke the `java -version` command.
+
+Additionally, the conversion tools are written in JavaScript for Node.js.
+You need to have Node.js and NPM installed.

          
A => openmsx.tcl +21 -0
@@ 0,0 1,21 @@ 
+# source lib/neonlib/tools/symbols.tcl
+# source lib/neonlib/tools/profile.tcl
+
+# symbols::load bin/vgmplay.sym
+
+ext debugdevice
+set debugoutput stdout
+debug set_watchpoint read_io 0x2E
+# debug set_watchpoint write_mem 0x0000 {([debug read ioports 0xA8] & 0x0C) == 0x04}
+# debug set_watchpoint read_mem 0x0000 {([debug read ioports 0xA8] & 0x0C) == 0x04}
+
+# diskmanipulator create /tmp/vgmplay.dsk 32M
+# virtual_drive /tmp/vgmplay.dsk
+# diskmanipulator format virtual_drive
+# diskmanipulator import virtual_drive bin/
+# virtual_drive eject
+# hda /tmp/vgmplay.dsk
+
+set maxframeskip 100
+set throttle off
+after time 20 "set throttle on"

          
A => package-lock.json +5 -0
@@ 0,0 1,5 @@ 
+{
+  "name": "gngplay",
+  "version": "0.0.0",
+  "lockfileVersion": 1
+}

          
A => package.json +14 -0
@@ 0,0 1,14 @@ 
+{
+  "name": "gngplay",
+  "version": "0.0.0",
+  "description": "Ghosts ’n Goblins music converter & player",
+  "author": "Laurens Holst",
+  "license": "BSD-2-Clause",
+  "repository": {
+    "type": "hg",
+    "url": "https://hg.sr.ht/~grauw/gngplay"
+  },
+  "type": "commonjs",
+  "dependencies": {},
+  "private": true
+}

          
A => res/resources.json +143 -0
@@ 0,0 1,143 @@ 
+{
+	"tickFrequency": 59.922743404312307,
+	"opnbFrequency": 8000000,
+	"opmFrequency": 3579545,
+	"waitThreshold": 50,
+	"tracks": [
+		{
+			"name": "01",
+			"path": "01 Credit.vgm",
+			"channelMapping": [1, -1, 2, -1, 5, -1, 6, -1]
+		},
+		{
+			"name": "02",
+			"path": "02 Start Demo.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "03",
+			"path": "03 Stage Introduction Map.vgm",
+			"channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "04",
+			"path": "04 Ground BGM (1, 2 Stage).vgm",
+			"channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "05",
+			"path": "05 Theme of Giant (1, 2 Stage Boss).vgm",
+			"channelMapping": [1, 4, 2, -1, 5, 6, 0, -1]
+		},
+		{
+			"name": "06",
+			"path": "06 Stage Clear (1).vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "07",
+			"path": "07 Stage Clear (2).vgm",
+			"channelMapping": [1, 2, 4, -1, 5, 6, 0, -1]
+		},
+		{
+			"name": "08",
+			"path": "08 Cave BGM (3, 4 Stage).vgm",
+			"channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "09",
+			"path": "09 Theme of Dragon (3, 4 Stage Boss).vgm",
+			"channelMapping": [1, 2, -1, -1, -1, 5, 6, -1]
+		},
+		{
+			"name": "10",
+			"path": "10 Final Stage Inside Great Demon King's Castle BGM (5, 6 Stage).vgm",
+			"channelMapping": [1, 2, 0, -1, 5, 6, 4, -1]
+		},
+		{
+			"name": "11",
+			"path": "11 Theme of Satan (5, 6 Stage Boss).vgm",
+			"channelMapping": [1, 2, 5, -1, 0, 6, 4, -1]
+		},
+		{
+			"name": "12",
+			"path": "12 Theme of Great Demon King.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "13",
+			"path": "13 Great Demon King Stage (7 Stage Boss).vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "14",
+			"path": "14 1st Lap Clear.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "15",
+			"path": "15 2nd Lap Clear.vgm",
+			"channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "16",
+			"path": "16 Hurry Up!.vgm",
+			"channelMapping": [1, 2, 0, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "17",
+			"path": "17 Player Out.vgm",
+			"channelMapping": [1, 2, 0, -1, 5, 6, 4, -1]
+		},
+		{
+			"name": "18",
+			"path": "18 Game Over.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "19",
+			"path": "19 1st Place Name Registration.vgm",
+			"channelMapping": [1, 2, 0, -1, 5, 6, -1, -1]
+		},
+		{
+			"name": "20",
+			"path": "20 1st Place Entry End.vgm",
+			"channelMapping": [1, 2, 0, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "21",
+			"path": "21 Below 2nd Place Name Registration.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 6, 4, -1]
+		},
+		{
+			"name": "22",
+			"path": "22 Below 2nd Place Entry End.vgm",
+			"channelMapping": [1, 0, 2, -1, 4, 5, 6, -1]
+		},
+		{
+			"name": "23",
+			"path": "23 Unused Jingle.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 6, -1, -1]
+		},
+		{
+			"name": "24",
+			"path": "24 Start Demo (1).vgm",
+			"channelMapping": [1, 2, 5, -1, -1, -1, -1, -1]
+		},
+		{
+			"name": "25",
+			"path": "25 Start Demo (2).vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "26",
+			"path": "26 Extend.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		},
+		{
+			"name": "27",
+			"path": "27 Credit.vgm",
+			"channelMapping": [1, 0, 2, -1, 5, 4, 6, -1]
+		}
+	]
+}

          
A => src/COM.asm +78 -0
@@ 0,0 1,78 @@ 
+;
+;
+;
+BDOS: equ 0005H
+FCB1: equ 005CH
+JIFFY: equ 0FC9EH
+
+FCB_RECORD_SIZE: equ 0EH
+FCB_FILE_SIZE: equ 10H
+FCB_RANDOM_RECORD_NUMBER: equ 21H
+
+_INNOE: equ 08H
+_CONST: equ 0BH
+_FOPEN: equ 0FH
+_FCLOSE: equ 10H
+_SETDTA: equ 1AH
+_RDBLK: equ 27H
+
+SoundData: equ 6000H
+
+	org 100H
+
+	jp Main
+
+	INCLUDE "Player.asm"
+	INCLUDE "Memory.asm"
+
+Main:
+	ld de,FCB1
+	ld c,_FOPEN
+	call BDOS
+	and a
+	ret nz
+	ld de,SoundData
+	ld c,_SETDTA
+	call BDOS
+	ld hl,1
+	ld (FCB1 + FCB_RECORD_SIZE),hl
+	ld hl,0
+	ld (FCB1 + FCB_RANDOM_RECORD_NUMBER),hl
+	ld (FCB1 + FCB_RANDOM_RECORD_NUMBER + 2),hl
+	ld hl,(FCB1 + FCB_FILE_SIZE)
+	ld de,FCB1
+	ld c,_RDBLK
+	call BDOS
+	ld de,FCB1
+	ld c,_FCLOSE
+	call BDOS
+	and a
+	ret nz
+	call Player_Detect
+	ld hl,SoundData
+	call Player_Play
+	ld a,(JIFFY)
+	ld c,a
+Main_Loop:
+	halt
+	ld a,(JIFFY)
+	ld b,a
+	sub c
+	jr z,Main_Loop
+	ld c,b
+	ld b,a
+Main_Tick_Loop:
+	push bc
+	call Player_Tick
+	pop bc
+	djnz Main_Tick_Loop
+	push bc
+	ld c,_CONST
+	call BDOS
+	pop bc
+	and a
+	jr z,Main_Loop
+	call Player_Stop
+	ld c,_INNOE
+	call BDOS
+	ret

          
A => src/Makoto.asm +195 -0
@@ 0,0 1,195 @@ 
+;
+; Makoto - Yamaha YM2608 OPNA
+;
+Makoto_BASE: equ 14H
+Makoto_STATUS0: equ Makoto_BASE  ; do not use, bus conflict with Music Module
+Makoto_STATUS1: equ Makoto_BASE + 2
+Makoto_FM1_ADDRESS: equ Makoto_BASE
+Makoto_FM1_DATA: equ Makoto_BASE + 1
+Makoto_FM2_ADDRESS: equ Makoto_BASE + 2
+Makoto_FM2_DATA: equ Makoto_BASE + 3
+Makoto_ADPCM_CONTROL: equ 00H
+
+; f <- c: found
+Makoto_Detect:
+	in a,(Makoto_STATUS1)
+	and 00111111B  ; ignore BUSY, D6 (N/C)
+	ret nz
+	ld a,Makoto_ADPCM_CONTROL
+	out (Makoto_FM2_ADDRESS),a
+	ld a,10000000B
+	out (Makoto_FM2_DATA),a
+	in a,(Makoto_STATUS1)
+	and 00111111B  ; ignore BUSY, D6 (N/C)
+	push af
+	ld a,Makoto_ADPCM_CONTROL
+	out (Makoto_FM2_ADDRESS),a
+	ld a,00000000B
+	out (Makoto_FM2_DATA),a
+	pop af
+	xor 00100000B
+	ret nz
+	ld a,1
+	ld (Makoto_found),a
+	ld de,2980H
+	call Makoto_WriteRegister1
+	scf
+	ret
+
+; hl = sound data
+; a <- wait amount
+Makoto_Process:
+Makoto_Process_Loop:
+	ld a,(hl)
+	inc hl
+	cp 80H
+	jr c,Makoto_Key
+	cp 88H
+	jr c,Makoto_Frequency
+	sub 90H
+	jr c,Makoto_Instrument
+	ret nz
+	ld e,(hl)
+	inc hl
+	ld d,(hl)
+	inc hl
+	add hl,de
+	jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Key:
+	ld e,a
+	and 0F8H
+	add a,e  ; shift bits 3-7 to the left
+	ld e,a
+	ld d,28H
+	call Makoto_WriteRegister1
+	jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Frequency:
+	bit 2,a
+	jr nz,Makoto_Frequency2
+Makoto_Frequency1:
+	and 3
+	add a,0A0H
+	push af
+	add a,4
+	ld d,a
+	inc hl
+	inc hl
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister1
+	pop de
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister1
+	jr Makoto_Process_Loop
+Makoto_Frequency2:
+	and 3
+	add a,0A0H
+	push af
+	add a,4
+	ld d,a
+	inc hl
+	inc hl
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister2
+	pop de
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister2
+	jr Makoto_Process_Loop
+
+; hl = sound data
+Makoto_Instrument:
+	bit 2,a
+	jr nz,Makoto_Instrument2
+Makoto_Instrument1:
+	and 3
+	add a,30H
+	ld b,24
+Makoto_Instrument1_Loop:
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister1
+	add a,4
+	djnz Makoto_Instrument1_Loop
+	add a,20H
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister1
+	jp Makoto_Process_Loop
+Makoto_Instrument2:
+	and 3
+	add a,30H
+	ld b,24
+Makoto_Instrument2_Loop:
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister2
+	add a,4
+	djnz Makoto_Instrument2_Loop
+	add a,20H
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Makoto_WriteRegister2
+	jp Makoto_Process_Loop
+
+Makoto_Mute:
+	ld b,10H
+	ld de,800FH
+Makoto_Mute_ReleaseRateLoop:
+	call Makoto_WriteRegister1
+	call Makoto_WriteRegister2
+	inc d
+	djnz Makoto_Mute_ReleaseRateLoop
+	ld b,08H
+	ld de,2800H
+Makoto_Mute_KeyOffLoop:
+	call Makoto_WriteRegister1
+	inc e
+	djnz Makoto_Mute_KeyOffLoop
+	ret
+
+; d = register
+; e = value
+Makoto_WriteRegister1:
+	push af
+Makoto_WriteRegister1_Wait:
+	in a,(Makoto_STATUS1)
+	rla
+	jr c,Makoto_WriteRegister1_Wait
+	ld a,d
+	out (Makoto_FM1_ADDRESS),a
+	jp $ + 3  ; wait 17 cycles (7.6 bus cycles)
+	ld a,e
+	out (Makoto_FM1_DATA),a
+	pop af
+	ret
+
+; d = register
+; e = value
+Makoto_WriteRegister2:
+	push af
+Makoto_WriteRegister2_Wait:
+	in a,(Makoto_STATUS1)
+	rla
+	jr c,Makoto_WriteRegister2_Wait
+	ld a,d
+	out (Makoto_FM2_ADDRESS),a
+	jp $ + 3  ; wait 17 cycles (7.6 bus cycles)
+	ld a,e
+	out (Makoto_FM2_DATA),a
+	pop af
+	ret
+
+; Data in RAM
+Makoto_found:
+	db 0

          
A => src/Memory.asm +130 -0
@@ 0,0 1,130 @@ 
+;
+;
+;
+RDSLT: equ 000CH
+WRSLT: equ 0014H
+CALSLT: equ 001CH
+ENASLT: equ 0024H
+EXPTBL: equ 0FCC1H
+
+; h = memory address high byte (bits 6-7: page)
+; a <- slot ID formatted FxxxSSPP
+; Modifies: f, bc, de
+Memory_GetSlot:
+    in a,(0A8H)
+    bit 7,h
+    jr z,Memory_GetSlot_PrimaryShiftContinue
+    rrca
+    rrca
+    rrca
+    rrca
+Memory_GetSlot_PrimaryShiftContinue:
+    bit 6,h
+    jr z,Memory_GetSlot_PrimaryShiftDone
+    rrca
+    rrca
+Memory_GetSlot_PrimaryShiftDone:
+    and 00000011B
+    ld c,a
+    ld b,0
+    ex de,hl
+    ld hl,EXPTBL
+    add hl,bc
+    ld a,(hl)
+    and 80H
+    or c
+    ld c,a
+    inc hl  ; move to SLTTBL
+    inc hl
+    inc hl
+    inc hl
+    ld a,(hl)
+    ex de,hl
+    bit 7,h
+    jr z,Memory_GetSlot_SecondaryShiftContinue
+    rrca
+    rrca
+    rrca
+    rrca
+Memory_GetSlot_SecondaryShiftContinue:
+    bit 6,h
+    jr nz,Memory_GetSlot_SecondaryShiftDone
+    rlca
+    rlca
+Memory_GetSlot_SecondaryShiftDone:
+    and 00001100B
+    or c
+    ret
+
+; Search all slots and subslots for a match.
+; Invoke Continue to continue searching where a previous search left off.
+; hl = detection routine (receives a = slot ID, can modify all)
+; f <- c: found
+; a <- slot number
+; Modifies: af, bc, de
+Memory_SearchSlots:
+	ld a,0
+Memory_SearchSlots_PrimaryLoop:
+	ex de,hl
+	ld hl,EXPTBL
+	ld b,0
+	ld c,a
+	add hl,bc
+	ld a,(hl)
+	ex de,hl
+	and 10000000B
+	or c
+Memory_SearchSlots_SecondaryLoop:
+	push af
+	push hl
+	call Memory_SearchSlots_JumpHL
+	pop hl
+	jp c,Memory_SearchSlots_Found
+	pop af
+	add a,00000100B
+	jp p,Memory_SearchSlots_NextPrimary
+	bit 4,a
+	jp z,Memory_SearchSlots_SecondaryLoop
+Memory_SearchSlots_NextPrimary:
+	inc a
+	and 00000011B
+	ret z  ; not found
+	jp Memory_SearchSlots_PrimaryLoop
+Memory_SearchSlots_Found:
+	pop af
+	scf
+	ret
+Memory_SearchSlots_JumpHL:
+	jp hl
+
+; Match a string in a slot.
+; a = slot
+; bc = string length
+; de = string
+; hl = address
+; f <- c: found
+; Modifies: f, bc, de, hl
+Memory_MatchSlotString:
+	push af
+	push bc
+	push de
+	call RDSLT
+	ei
+	pop de
+	pop bc
+	ex de,hl
+	cpi
+	jr nz,Memory_MatchSlotString_NotFound
+	jp po,Memory_MatchSlotString_Found
+	inc de
+	ex de,hl
+	pop af
+	jp Memory_MatchSlotString
+Memory_MatchSlotString_Found:
+	pop af
+	scf
+	ret
+Memory_MatchSlotString_NotFound:
+	pop af
+	and a
+	ret

          
A => src/Neotron.asm +232 -0
@@ 0,0 1,232 @@ 
+;
+; Supersoniqs Neotron / JunSoft OSC1N0 - Yamaha YM2610(B) OPNB
+;
+Neotron_BASE: equ 0BC00H
+Neotron_STATUS0: equ Neotron_BASE
+Neotron_STATUS1: equ Neotron_BASE + 2
+Neotron_FM1_ADDRESS: equ Neotron_BASE
+Neotron_FM1_DATA: equ Neotron_BASE + 1
+Neotron_FM2_ADDRESS: equ Neotron_BASE + 2
+Neotron_FM2_DATA: equ Neotron_BASE + 3
+Neotron_ID_ADDRESS: equ 806CH
+Neotron_SIOS_ENABLE_IO: equ 801CH
+
+; f <- c: found
+Neotron_Detect:
+	ld hl,Neotron_Detect_MatchID
+	call Memory_SearchSlots
+	ret nc
+	push af
+	ld iyh,a
+	ld (Neotron_slot),a
+	ld a,1
+	ld (Neotron_found),a
+	ld ix,Neotron_SIOS_ENABLE_IO
+	and a
+	call CALSLT
+	ei
+	pop af
+	ret
+
+; a = slot ID
+; f <- c: found
+Neotron_Detect_MatchID:
+	ld de,Neotron_id
+	ld hl,Neotron_ID_ADDRESS
+	ld bc,18
+	jp Memory_MatchSlotString
+
+; hl = sound data
+; a <- wait amount
+Neotron_Process:
+	exx
+	ld h,Neotron_BASE >> 8
+	call Memory_GetSlot
+	ex af,af'
+	ld a,(Neotron_slot)
+	ld h,Neotron_BASE >> 8
+	call ENASLT
+	ei
+	exx
+	call Neotron_Process_Loop
+	exx
+	ex af,af'
+	ld h,Neotron_BASE >> 8
+	call ENASLT
+	ei
+	ex af,af'
+	exx
+	ret
+
+; hl = sound data
+; a <- wait amount
+Neotron_Process_Loop:
+	ld a,(hl)
+	inc hl
+	cp 80H
+	jr c,Neotron_Key
+	cp 88H
+	jr c,Neotron_Frequency
+	sub 90H
+	jr c,Neotron_Instrument
+	ret nz
+	ld e,(hl)
+	inc hl
+	ld d,(hl)
+	inc hl
+	add hl,de
+	jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Key:
+	ld e,a
+	and 0F8H
+	add a,e  ; shift bits 3-7 to the left
+	ld e,a
+	ld d,28H
+	call Neotron_WriteRegister1
+	jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Frequency:
+	bit 2,a
+	jr nz,Neotron_Frequency2
+Neotron_Frequency1:
+	and 3
+	add a,0A0H
+	push af
+	add a,4
+	ld d,a
+	inc hl
+	inc hl
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister1
+	pop de
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister1
+	jr Neotron_Process_Loop
+Neotron_Frequency2:
+	and 3
+	add a,0A0H
+	push af
+	add a,4
+	ld d,a
+	inc hl
+	inc hl
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister2
+	pop de
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister2
+	jr Neotron_Process_Loop
+
+; hl = sound data
+Neotron_Instrument:
+	bit 2,a
+	jr nz,Neotron_Instrument2
+	and 3
+	add a,30H
+	ld b,24
+Neotron_Instrument1_Loop:
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister1
+	add a,4
+	djnz Neotron_Instrument1_Loop
+	add a,20H
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister1
+	jp Neotron_Process_Loop
+Neotron_Instrument2:
+	and 3
+	add a,30H
+	ld b,24
+Neotron_Instrument2_Loop:
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister2
+	add a,4
+	djnz Neotron_Instrument2_Loop
+	add a,20H
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call Neotron_WriteRegister2
+	jp Neotron_Process_Loop
+
+Neotron_Mute:
+	ld h,Neotron_BASE >> 8
+	call Memory_GetSlot
+	ex af,af'
+	ld a,(Neotron_slot)
+	ld h,Neotron_BASE >> 8
+	call ENASLT
+	ei
+	ld b,10H
+	ld de,800FH
+Neotron_Mute_ReleaseRateLoop:
+	call Neotron_WriteRegister1
+	call Neotron_WriteRegister2
+	inc d
+	djnz Neotron_Mute_ReleaseRateLoop
+	ld b,08H
+	ld de,2800H
+Neotron_Mute_KeyOffLoop:
+	call Neotron_WriteRegister1
+	inc e
+	djnz Neotron_Mute_KeyOffLoop
+	ex af,af'
+	ld h,Neotron_BASE >> 8
+	call ENASLT
+	ei
+	ret
+
+; d = register
+; e = value
+Neotron_WriteRegister1:
+	push af
+Neotron_WriteRegister1_Wait:
+	ld a,(Neotron_STATUS0)
+	rla
+	jr c,Neotron_WriteRegister1_Wait
+	ld a,d
+	ld (Neotron_FM1_ADDRESS),a
+	jp $ + 3  ; wait 17 cycles (7.6 bus cycles)
+	ld a,e
+	ld (Neotron_FM1_DATA),a
+	pop af
+	ret
+
+; d = register
+; e = value
+Neotron_WriteRegister2:
+	push af
+Neotron_WriteRegister2_Wait:
+	ld a,(Neotron_STATUS0)
+	rla
+	jr c,Neotron_WriteRegister2_Wait
+	ld a,d
+	ld (Neotron_FM2_ADDRESS),a
+	jp $ + 3  ; wait 17 cycles (7.6 bus cycles)
+	ld a,e
+	ld (Neotron_FM2_DATA),a
+	pop af
+	ret
+
+;
+Neotron_id:
+	db "OSC YM  OPNBYM2610"
+
+; Data in RAM
+Neotron_found:
+	db 0
+Neotron_slot:
+	db 0

          
A => src/Player.asm +71 -0
@@ 0,0 1,71 @@ 
+;
+;
+;
+	INCLUDE "SFG.asm"
+	INCLUDE "Makoto.asm"
+	INCLUDE "Neotron.asm"
+
+; f <- c: found
+Player_Detect:
+	call SFG_Detect
+	ld bc,SFG_Process
+	ld de,SFG_Mute
+	jr c,Player_SetProcessAndMute
+	call Makoto_Detect
+	ld bc,Makoto_Process
+	ld de,Makoto_Mute
+	jr c,Player_SetProcessAndMute
+	call Neotron_Detect
+	ld bc,Neotron_Process
+	ld de,Neotron_Mute
+	jr c,Player_SetProcessAndMute
+	ld a,0C9H  ; ret
+	ld (Player_Process),a
+	ld (Player_Mute),a
+	ret
+
+; bc = process function
+; de = mute function
+Player_SetProcessAndMute:
+	ld a,0C3H  ; jp
+	ld (Player_Process),a
+	ld (Player_Process + 1),bc
+	ld (Player_Mute),a
+	ld (Player_Mute + 1),de
+	ret
+
+; hl = sound data
+Player_Play:
+	ld (Player_position),hl
+	ld a,1
+	ld (Player_wait),a
+	ret
+
+Player_Stop:
+	ld hl,0
+	ld (Player_position),hl
+	call Player_Mute
+	ret
+
+Player_Tick:
+	ld hl,Player_wait
+	dec (hl)
+	ret nz
+	ld hl,(Player_position)
+	ld a,l
+	or h
+	ret z
+	call Player_Process
+	ld (Player_wait),a
+	ld (Player_position),hl
+	ret
+
+; Data in RAM
+Player_position:
+	dw 0
+Player_wait:
+	db 0
+Player_Process:
+	db 0C9H, 0, 0
+Player_Mute:
+	db 0C9H, 0, 0

          
A => src/SFG.asm +163 -0
@@ 0,0 1,163 @@ 
+;
+; Yamaha SFG-01 / SFG-05 - Yamaha YM2151 OPM
+;
+SFG_BASE: equ 0BFF0H
+SFG_ADDRESS: equ SFG_BASE
+SFG_DATA: equ SFG_BASE + 1
+SFG_STATUS: equ SFG_BASE + 1
+SFG_ID_ADDRESS: equ 80H
+
+; f <- c: found
+SFG_Detect:
+	ld hl,SFG_Detect_MatchID
+	call Memory_SearchSlots
+	ret nc
+	ld (SFG_slot),a
+	ld a,1
+	ld (SFG_found),a
+	ret
+
+; a = slot ID
+; f <- c: found
+SFG_Detect_MatchID:
+	ld de,SFG_id
+	ld hl,SFG_ID_ADDRESS
+	ld bc,6
+	jp Memory_MatchSlotString
+
+; hl = sound data
+; a <- wait amount
+SFG_Process:
+	exx
+	ld h,SFG_BASE >> 8
+	call Memory_GetSlot
+	ex af,af'
+	ld a,(SFG_slot)
+	ld h,SFG_BASE >> 8
+	call ENASLT
+	ei
+	exx
+	call SFG_Process_Loop
+	exx
+	ex af,af'
+	ld h,SFG_BASE >> 8
+	call ENASLT
+	ei
+	ex af,af'
+	exx
+	ret
+
+; hl = sound data
+; a <- wait amount
+SFG_Process_Loop:
+	ld a,(hl)
+	inc hl
+	cp 80H
+	jr c,SFG_Key
+	cp 88H
+	jr c,SFG_Frequency
+	sub 90H
+	jr c,SFG_Instrument
+	ret nz
+	ld e,(hl)
+	inc hl
+	ld d,(hl)
+	inc hl
+	add hl,de
+	jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Key:
+	ld e,a
+	ld d,08H
+	call SFG_WriteRegister
+	jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Frequency:
+	and 7
+	add a,28H
+	push af
+	add a,8
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call SFG_WriteRegister
+	pop de
+	ld e,(hl)
+	inc hl
+	call SFG_WriteRegister
+	inc hl
+	inc hl
+	jr SFG_Process_Loop
+
+; hl = sound data
+SFG_Instrument:
+	and 7
+	add a,40H
+	ld b,24
+SFG_Instrument_Loop:
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call SFG_WriteRegister
+	add a,8
+	djnz SFG_Instrument_Loop
+	add a,20H
+	ld d,a
+	ld e,(hl)
+	inc hl
+	call SFG_WriteRegister
+	jr SFG_Process_Loop
+
+SFG_Mute:
+	ld h,SFG_BASE >> 8
+	call Memory_GetSlot
+	ex af,af'
+	ld a,(SFG_slot)
+	ld h,SFG_BASE >> 8
+	call ENASLT
+	ei
+	ld b,20H
+	ld de,0E00FH
+SFG_Mute_ReleaseRateLoop:
+	call SFG_WriteRegister
+	inc d
+	djnz SFG_Mute_ReleaseRateLoop
+	ld b,08H
+	ld de,0800H
+SFG_Mute_KeyOffLoop:
+	call SFG_WriteRegister
+	inc e
+	djnz SFG_Mute_KeyOffLoop
+	ex af,af'
+	ld h,SFG_BASE >> 8
+	call ENASLT
+	ei
+	ret
+
+; d = register
+; e = value
+SFG_WriteRegister:
+	push af
+SFG_WriteRegister_Wait:
+	ld a,(SFG_STATUS)
+	rla
+	jr c,SFG_WriteRegister_Wait
+	ld a,d
+	ld (SFG_ADDRESS),a
+	cp (hl)  ; R800 wait: ~4 bus cycles
+	ld a,e
+	ld (SFG_DATA),a
+	pop af
+	ret
+
+;
+SFG_id:
+	db "MCHFM0"
+
+; Data in RAM
+SFG_found:
+	db 0
+SFG_slot:
+	db 0

          
A => tools/audio.js +553 -0
@@ 0,0 1,553 @@ 
+"use strict"
+const fs = require('fs');
+const { checkTypes } = require('./utils');
+
+class AudioTrack {
+	constructor(name) {
+		checkTypes(arguments, "string");
+		this.name = name;
+		this.commands = [];
+		this.loop = -1;
+	}
+
+	addCommand(command) {
+		checkTypes(arguments, AudioCommand);
+		if (command instanceof AudioCommandWait && this.commands.length > 0) {
+			if (this.commands[this.commands.length - 1] instanceof AudioCommandWait) {
+				this.commands.push(command.add(this.commands.pop()));
+				return;
+			}
+		} else if (command instanceof AudioCommandLoop) {
+			this.loop = this.commands.length;
+		} else if (command instanceof AudioCommandFMInstrument) {
+			let tail = [];
+			while (this.commands.length > 0 && this.commands[this.commands.length - 1] instanceof AudioCommandFMInstrument) {
+				const lastCommand = this.commands.pop();
+				if (lastCommand.channel == command.channel) {
+					break;
+				} else {
+					tail.push(lastCommand);
+				}
+			}
+			while (tail.length > 0) {
+				this.commands.push(tail.pop());
+			}
+		}
+		this.commands.push(command);
+	}
+
+	addCommands(commands) {
+		checkTypes(arguments, Array);
+		for (const command of commands)
+			this.addCommand(command);
+	}
+
+	moveWaitAfterLoop() {
+		if (this.loop >= 0 && this.commands[this.loop + 1] instanceof AudioCommandWait) {
+			const track = new AudioTrack(this.name);
+			track.addCommands(this.commands.slice(0, this.loop));
+			track.addCommand(this.commands[this.loop + 1]);
+			track.addCommand(this.commands[this.loop]);
+			track.addCommands(this.commands.slice(this.loop + 2, this.commands.length - 1));
+			track.addCommand(this.commands[this.loop + 1]);
+			track.addCommand(this.commands[this.commands.length - 1]);
+			return track;
+		} else {
+			return this;
+		}
+	}
+
+	removeWaitsUnder(threshold) {
+		checkTypes(arguments, "number");
+		const track = new AudioTrack(this.name);
+		let accumulator = 0;
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandWait) {
+				if (command.time >= threshold) {
+					track.addCommand(new AudioCommandWait(command.time + accumulator, 44100));
+					accumulator = 0;
+				} else {
+					accumulator += command.time;
+				}
+			} else if (command instanceof AudioCommandLoop || command instanceof AudioCommandEnd) {
+				if (accumulator > 0) {
+					track.addCommand(new AudioCommandWait(accumulator, 44100));
+					accumulator = 0;
+				}
+				track.addCommand(command);
+			} else {
+				track.addCommand(command);
+			}
+		}
+		return track;
+	}
+
+	quantise(newTickFrequency) {
+		checkTypes(arguments, "number");
+		const track = new AudioTrack(this.name);
+		let time = 0;
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandWait) {
+				time += command.time * newTickFrequency;
+				if (time >= command.base) {
+					const newTime = Math.floor(time / command.base) * command.base;
+					track.addCommand(new AudioCommandWait(newTime / newTickFrequency, command.base));
+					time -= newTime;
+				}
+			} else {
+				track.addCommand(command);
+			}
+		}
+		return track;
+	}
+
+	trimLeadingSilence() {
+		const track = new AudioTrack(this.name);
+		for (let index = 0; index < this.commands.length; ++index) {
+			if (!(this.commands[index] instanceof AudioCommandWait)) {
+				track.addCommands(this.commands.slice(index, this.commands.length));
+				break;
+			}
+		}
+		return track;
+	}
+
+	toFMCommands() {
+		const track = new AudioTrack(this.name);
+		const registers = new Array(512).fill(0);
+		let clockDivider = 6;
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandOPNB) {
+				registers[command.address] = command.value;
+				const addressLSB = command.address & 0xFF;
+				const channel = (command.address >> 8) << 2 | command.address & 3;
+				if (command.address == 0x28) {
+					track.addCommand(new AudioCommandFMKey(command.value & 7, command.value >> 4));
+				} else if (addressLSB >= 0xA0 && addressLSB < 0xA4) {
+					const value = registers[command.address + 4] << 8 | registers[command.address];
+					track.addCommand(AudioCommandFMFrequency.ofFNum(channel, (value & 0x7FF) << (value >> 11), command.clockFrequency / clockDivider));
+				} else if (addressLSB >= 0x30 && addressLSB < 0x90 || addressLSB >= 0xB0 && addressLSB < 0xB4) {
+					const offset = (channel >> 2) << 8 | channel & 3;
+					track.addCommand(new AudioCommandFMInstrument(channel, [
+						registers[0x30 + offset], registers[0x34 + offset], registers[0x38 + offset], registers[0x3C + offset],
+						registers[0x40 + offset], registers[0x44 + offset], registers[0x48 + offset], registers[0x4C + offset],
+						registers[0x50 + offset], registers[0x54 + offset], registers[0x58 + offset], registers[0x5C + offset],
+						registers[0x60 + offset], registers[0x64 + offset], registers[0x68 + offset], registers[0x6C + offset],
+						registers[0x70 + offset], registers[0x74 + offset], registers[0x78 + offset], registers[0x7C + offset],
+						registers[0x80 + offset], registers[0x84 + offset], registers[0x88 + offset], registers[0x8C + offset],
+						registers[0xB0 + offset] | 0xC0
+					]));
+				} else if (command.address >= 0x2D && command.address < 0x30) {
+					clockDivider = [6, 3, 2][command.address - 0x2D];
+				}
+			} else {
+				track.addCommand(command);
+			}
+		}
+		return track;
+	}
+
+	mapChannels(mapping) {
+		checkTypes(arguments, Array);
+		const track = new AudioTrack(this.name);
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandFMChannel) {
+				const newChannel = mapping[command.channel];
+				if (newChannel < 0) {
+					// skip
+				} else if (command instanceof AudioCommandFMKey) {
+					track.addCommand(new AudioCommandFMKey(mapping[command.channel], command.slots));
+				} else if (command instanceof AudioCommandFMFrequency) {
+					track.addCommand(new AudioCommandFMFrequency(mapping[command.channel], command.frequency));
+				} else if (command instanceof AudioCommandFMInstrument) {
+					track.addCommand(new AudioCommandFMInstrument(mapping[command.channel], command.parameters));
+				}
+			} else {
+				track.addCommand(command);
+			}
+		}
+		return track;
+	}
+
+	frequencyBeforeKeyOn() {
+		const track = new AudioTrack(this.name);
+		let lastCommand = null;
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandFMFrequency && lastCommand instanceof AudioCommandFMKey &&
+				lastCommand.slots && command.channel == lastCommand.channel) {
+				track.commands.pop();
+				track.addCommand(command);
+				track.addCommand(lastCommand);
+			} else {
+				track.addCommand(command);
+			}
+			lastCommand = command;
+		}
+		return track;
+	}
+
+	async toGNG(tickFrequency, opnFrequency, opmFrequency) {
+		checkTypes(arguments, "number", "number");
+		const word = value => [value & 0xFF, (value >> 8) & 0xFF];
+		let samples = 0;
+		let loop = -1;
+		const data = [];
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandFMKey) {
+				data.push(0x00 | command.slots << 3 | command.channel);
+			} else if (command instanceof AudioCommandFMFrequency) {
+				const keyCodeFraction = command.getKeyCodeFraction(opmFrequency);
+				const fNumBlock = command.getFNumBlock(opnFrequency / 6);
+				data.push(0x80 + command.channel, ...word(keyCodeFraction), fNumBlock >> 8, fNumBlock & 0xFF);
+			} else if (command instanceof AudioCommandFMInstrument) {
+				data.push(0x88 + command.channel, ...command.parameters);
+			} else if (command instanceof AudioCommandWait) {
+				samples += Math.round(command.getTime(44100));
+				while (samples >= 44100 / tickFrequency) {
+					const waitTicks = Math.min(Math.floor(samples * tickFrequency / 44100), 0x6F);
+					data.push(0x90 + waitTicks);
+					samples -= waitTicks * 44100 / tickFrequency;
+				}
+			} else if (command instanceof AudioCommandLoop) {
+				loop = data.length;
+			} else if (command instanceof AudioCommandEnd) {
+				if (loop >= 0) {
+					data.push(0x90, ...word(loop - data.length - 3));
+				} else {
+					data.push(0x90 + 1);  // wait 1
+					data.push(0x90, ...word(-4));  // loop to wait
+				}
+			}
+		}
+		await fs.promises.writeFile(`bin/${this.name}.gng`, Buffer.from(data));
+	}
+
+	async toVGMOPNB(opnbFrequency) {
+		checkTypes(arguments, "number");
+		let samples = 0;
+		let loopOffset = -1;
+		let loopSamples = -1;
+		const commandData = [];
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandOPNB) {
+				commandData.push(0x58 | command.address >> 8, command.address & 0xFF, command.value);
+			} else if (command instanceof AudioCommandFMKey) {
+				commandData.push(0x58, 0x28, command.slots << 4 | command.channel);
+			} else if (command instanceof AudioCommandFMFrequency) {
+				const fNumBlock = command.getFNumBlock(opnbFrequency / 6);
+				commandData.push(0x58 + (command.channel >> 2), 0xA4 + (command.channel & 3), fNumBlock >> 8);
+				commandData.push(0x58 + (command.channel >> 2), 0xA0 + (command.channel & 3), fNumBlock & 0xFF);
+			} else if (command instanceof AudioCommandFMInstrument) {
+				for (let i = 0; i < 24; i++)
+					commandData.push(0x58 + (command.channel >> 2), 0x30 + i * 4 + (command.channel & 3), command.parameters[i]);
+				commandData.push(0x58 + (command.channel >> 2), 0xB0 + (command.channel & 3), command.parameters[24]);
+			} else if (command instanceof AudioCommandWait) {
+				let ticks = Math.round(command.getTime(44100));
+				samples += ticks;
+				while (ticks > 0) {
+					const ticksCapped = Math.min(ticks, 65535);
+					commandData.push(0x61, ticksCapped & 0xFF, ticksCapped >> 8);
+					ticks -= ticksCapped;
+				}
+			} else if (command instanceof AudioCommandLoop) {
+				loopOffset = commandData.length;
+				loopSamples = samples;
+			} else if (command instanceof AudioCommandEnd) {
+				commandData.push(0x66);
+			}
+		}
+
+		const data = [];
+		const doubleWord = value => [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF];
+		data.push(0x56, 0x67, 0x6d, 0x20);  // 00 VGM ID
+		data.push(...doubleWord(0x80 + commandData.length - 0x04));  // 04 EOF offset
+		data.push(...doubleWord(0x151));  // 08 version number
+		data.push(0x00, 0x00, 0x00, 0x00);  // 0C SN76489 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 10 YM2413 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 14 GD3 offset
+		data.push(...doubleWord(samples));  // 18 Total # samples
+		data.push(...doubleWord(loopOffset >= 0 ? 0x80 + loopOffset - 0x1C : 0));  // 1C Loop offset
+		data.push(...doubleWord(loopSamples >= 0 ? samples - loopSamples : 0));  // 20 Loop # samples
+		data.push(0x00, 0x00, 0x00, 0x00);  // 24 Rate
+		data.push(0x00, 0x00, 0x00, 0x00);  // 28 SN76489 flags
+		data.push(0x00, 0x00, 0x00, 0x00);  // 2C YM2612 / YM3438 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 30 YM2151 / YM2164 clock
+		data.push(...doubleWord(0x80 - 0x34));  // 34 VGM data offset
+		data.push(0x00, 0x00, 0x00, 0x00);  // 38 Sega PCM clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 3C Sega PCM interface register
+		data.push(0x00, 0x00, 0x00, 0x00);  // 40 RF5C68 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 44 YM2203 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 48 YM2608 clock
+		// data.push(...doubleWord(opnbFrequency | Math.pow(2, 31)));  // 4C YM2610 / YM2610B clock
+		data.push(...doubleWord(opnbFrequency));  // 4C YM2610 / YM2610B clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 50 YM3812 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 54 YM3526 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 58 Y8950 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 5C YMF262 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 60 YMF278B clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 64 YMF271 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 68 YMZ280B clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 6C RF5C164 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 70 PWM clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 74 AY8910 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 78 YM2203 / YM2608/AY8910 flags
+		data.push(0x00, 0x00, 0x00, 0x00);  // 7C Volume / loop modifiers
+		data.push(...commandData);
+
+		await fs.promises.writeFile(`bin/${this.name}-opnb.vgm`, Buffer.from(data));
+	}
+
+	async toVGMOPM(opmFrequency) {
+		checkTypes(arguments, "number");
+		let samples = 0;
+		let loopOffset = -1;
+		let loopSamples = -1;
+		const commandData = [];
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandOPNB) {
+				throw new Error("Use toFMCommands() first.");
+			} else if (command instanceof AudioCommandFMKey) {
+				commandData.push(0x54, 0x08, command.slots << 3 | command.channel);
+			} else if (command instanceof AudioCommandFMFrequency) {
+				const keyCodeFraction = command.getKeyCodeFraction(opmFrequency);
+				commandData.push(0x54, 0x30 + command.channel, keyCodeFraction & 0xFF);
+				commandData.push(0x54, 0x28 + command.channel, keyCodeFraction >> 8);
+			} else if (command instanceof AudioCommandFMInstrument) {
+				for (let i = 0; i < 24; i++)
+					commandData.push(0x54, 0x40 + i * 8 + command.channel, command.parameters[i]);
+				commandData.push(0x54, 0x20 + command.channel, command.parameters[24]);
+			} else if (command instanceof AudioCommandWait) {
+				let ticks = Math.round(command.getTime(44100));
+				samples += ticks;
+				while (ticks > 0) {
+					const ticksCapped = Math.min(ticks, 65535);
+					commandData.push(0x61, ticksCapped & 0xFF, ticksCapped >> 8);
+					ticks -= ticksCapped;
+				}
+			} else if (command instanceof AudioCommandLoop) {
+				loopOffset = commandData.length;
+				loopSamples = samples;
+			} else if (command instanceof AudioCommandEnd) {
+				commandData.push(0x66);
+			}
+		}
+
+		const data = [];
+		const doubleWord = value => [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF];
+		data.push(0x56, 0x67, 0x6d, 0x20);  // 00 VGM ID
+		data.push(...doubleWord(0x40 + commandData.length - 0x04));  // 04 EOF offset
+		data.push(...doubleWord(0x151));  // 08 version number
+		data.push(0x00, 0x00, 0x00, 0x00);  // 0C SN76489 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 10 YM2413 clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 14 GD3 offset
+		data.push(...doubleWord(samples));  // 18 Total # samples
+		data.push(...doubleWord(loopOffset >= 0 ? 0x40 + loopOffset - 0x1C : 0));  // 1C Loop offset
+		data.push(...doubleWord(loopSamples >= 0 ? samples - loopSamples : 0));  // 20 Loop # samples
+		data.push(0x00, 0x00, 0x00, 0x00);  // 24 Rate
+		data.push(0x00, 0x00, 0x00, 0x00);  // 28 SN76489 flags
+		data.push(0x00, 0x00, 0x00, 0x00);  // 2C YM2612 / YM3438 clock
+		data.push(...doubleWord(opmFrequency));  // 30 YM2151 / YM2164 clock
+		data.push(...doubleWord(0x40 - 0x34));  // 34 VGM data offset
+		data.push(0x00, 0x00, 0x00, 0x00);  // 38 Sega PCM clock
+		data.push(0x00, 0x00, 0x00, 0x00);  // 3C Sega PCM interface register
+		data.push(...commandData);
+
+		await fs.promises.writeFile(`bin/${this.name}-opm.vgm`, Buffer.from(data));
+	}
+
+	getUnusedChannels() {
+		const usedChannels = new Array(8).fill(false);
+		const keyOnChannels = new Array(8).fill(false);
+		for (const command of this.commands) {
+			if (command instanceof AudioCommandFMChannel) {
+				usedChannels[command.channel] = true;
+				if (command instanceof AudioCommandFMKey && command.slots) {
+					keyOnChannels[command.channel] = true;
+				}
+			}
+		}
+		const unusedChannels = [];
+		for (let i = 0; i < 8; ++i) {
+			if (usedChannels[i] && !keyOnChannels[i]) {
+				unusedChannels.push(i);
+			}
+		}
+		return unusedChannels;
+	}
+}
+
+class AudioCommand {
+	constructor() {
+	}
+}
+
+class AudioCommandOPNB extends AudioCommand {
+	constructor(address, value, clockFrequency) {
+		checkTypes(arguments, "number", "number", "number");
+		if (address >= 0xC0 && address < 0x100 || address >= 0x1C0)
+			throw new Error("Invalid OPN register.");
+		if ((address & 0xFF) >= 0x90 && (address & 0xFF) < 0xA0)
+			throw new Error("SSG-EG not supported.");
+		if (address == 0x27 && value & 0xC0)
+			throw new Error("CSM or 3ch modes not supported.");
+		super();
+		this.address = address;
+		this.value = value;
+		this.clockFrequency = clockFrequency;
+	}
+}
+
+class AudioCommandFMChannel extends AudioCommand {
+	constructor(channel) {
+		checkTypes(arguments, "number");
+		if (channel < 0 || channel >= 8)
+			throw new Error("Channel out of range.");
+		super();
+		this.channel = channel;
+	}
+}
+
+class AudioCommandFMKey extends AudioCommandFMChannel {
+	constructor(channel, slots) {
+		checkTypes(arguments, "number", "number");
+		super(channel);
+		this.slots = slots;
+	}
+}
+
+class AudioCommandFMFrequency extends AudioCommandFMChannel {
+	constructor(channel, frequency) {
+		checkTypes(arguments, "number", "number");
+		super(channel);
+		this.frequency = frequency;
+	}
+
+	getFNumBlock(opnFrequency) {
+		checkTypes(arguments, "number");
+		const fNum = Math.min(this.frequency * ((24 << 21) / opnFrequency), 0x3FFFF);
+		const block = Math.max(Math.floor(Math.log2(fNum) - 10), 0);
+		const fNumRounded = fNum + Math.pow(2, block - 1);
+		const blockRounded = Math.max(Math.floor(Math.log2(fNumRounded) - 10), 0);
+		return blockRounded << 11 | fNumRounded >> block;
+	}
+
+	getKeyCodeFraction(opmFrequency) {
+		checkTypes(arguments, "number");
+		const logarithm = Math.log2(this.frequency / 440 * (3579545 / opmFrequency)) + 4 + 8 / 12;
+		const keyFraction = Math.min(Math.max(Math.round(logarithm * 12 * 64), 0), 8 * 12 * 64 - 1);
+		const note = Math.floor(keyFraction / 64);
+		const octave = Math.floor(note / 12);
+		const noteMap = [0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14];
+		return octave << 12 | noteMap[note % 12] << 8 | keyFraction % 64 << 2;
+	}
+
+	static ofFNum(channel, fNum, opnFrequency) {
+		checkTypes(arguments, "number", "number", "number");
+		if (fNum < 0 || fNum > 0x3FFFF)
+			throw new Error("FNum out of range.");
+		return new AudioCommandFMFrequency(channel, fNum * (opnFrequency / (24 << 21)));
+	}
+}
+
+class AudioCommandFMInstrument extends AudioCommandFMChannel {
+	constructor(channel, parameters) {
+		checkTypes(arguments, "number", Array);
+		if (parameters.length != 25)
+			throw new Error("Incorrect number of parameters.");
+		super(channel);
+		this.parameters = parameters;
+	}
+}
+
+class AudioCommandWait extends AudioCommand {
+	constructor(time, tickFrequency) {
+		checkTypes(arguments, "number", "number");
+		super();
+		this.time = time;
+		this.tickFrequency = tickFrequency;
+	}
+
+	getTime(tickFrequency) {
+		checkTypes(arguments, "number");
+		return this.time * tickFrequency / this.tickFrequency;
+	}
+
+	add(other) {
+		checkTypes(arguments, AudioCommandWait);
+		if (other.tickFrequency != this.tickFrequency)
+			throw new Error("Base timing frequency mismatch.");
+		return new AudioCommandWait(this.time + other.time, this.tickFrequency);
+	}
+}
+
+class AudioCommandLoop extends AudioCommand {
+	constructor() {
+		super();
+	}
+}
+
+class AudioCommandEnd extends AudioCommand {
+	constructor() {
+		super();
+	}
+}
+
+class VGMLoader {
+	constructor(name, path) {
+		checkTypes(arguments, "string", "string");
+		this.name = name;
+		this.path = path;
+	}
+
+	async loadTrack() {
+		const vgm = await fs.promises.readFile(this.path);
+		const getByte = address => vgm[address];
+		const getWord = address => getByte(address) | getByte(address + 1) << 8;
+		const getDoubleWord = address => getWord(address) | getWord(address + 2) << 16;
+		if (getDoubleWord(0x00) != 0x206d6756)
+			throw new Error("Not a VGM file.");
+		const version = getDoubleWord(0x08);
+		const loop = getDoubleWord(0x1C) ? getDoubleWord(0x1C) + 0x1C : 0;
+		const headerSize = version >= 0x150 ? getDoubleWord(0x34) + 0x34 : 0x40;
+		const opnFrequency = version >= 0x150 && headerSize >= 0x48 ? getDoubleWord(0x44) & 0x3FFFFFFF : 0;
+		const tickFrequency = 44100;
+		const track = new AudioTrack(this.name);
+		let address = headerSize;
+		while (address < vgm.length) {
+			if (address == loop)
+				track.addCommand(new AudioCommandLoop());
+			const type = getByte(address++);
+			if (type == 0x55) {
+				const register = getByte(address++);
+				const value = getByte(address++);
+				track.addCommand(new AudioCommandOPNB(register, value, opnFrequency * 2));
+			} else if (type == 0xA5) {
+				const register = getByte(address++);
+				const value = getByte(address++);
+				if (register == 0x28) {
+					track.addCommand(new AudioCommandOPNB(register, value | 4, opnFrequency * 2));
+				} else if (register >= 0x30) {
+					track.addCommand(new AudioCommandOPNB(register | 0x100, value, opnFrequency * 2));
+				}
+			} else if (type == 0x61) {
+				track.addCommand(new AudioCommandWait(getWord(address), tickFrequency));
+				address += 2;
+			} else if (type == 0x62) {
+				track.addCommand(new AudioCommandWait(735, tickFrequency));
+			} else if (type == 0x63) {
+				track.addCommand(new AudioCommandWait(882, tickFrequency));
+			} else if (type == 0x66) {
+				track.addCommand(new AudioCommandEnd());
+				break;
+			} else if (type >= 0x70 && type < 0x80) {
+				track.addCommand(new AudioCommandWait((type & 15) + 1, tickFrequency));
+			} else {
+				throw new Error(`Unsupported command ${type.toString(16)} at address ${(address - 1).toString(16)}`);
+			}
+		}
+		return track;
+	}
+}
+
+exports.AudioTrack = AudioTrack;
+exports.VGMLoader = VGMLoader;

          
A => tools/glass.jar +0 -0

        
A => tools/resources.js +86 -0
@@ 0,0 1,86 @@ 
+"use strict"
+const fs = require('fs');
+const path = require('path');
+const { checkTypes } = require('./utils');
+const { AudioTrack, VGMLoader } = require('./audio');
+
+async function main() {
+	if (process.argv.length < 3) {
+		console.log(`Usage: node ${process.argv[1]} resources.json`);
+		process.exit(1);
+	}
+
+	const path = process.argv[2];
+
+	const resources = await new ResourcesLoader(path).parse();
+	await resources.write();
+
+	for (const track of resources.tracks) {
+		const unusedChannels = track.toFMCommands().getUnusedChannels();
+		if (unusedChannels.length > 0) {
+			console.log(`Warning: Track ${track.name} has unused channels: ${unusedChannels.join(", ")}`);
+		}
+	}
+}
+
+class Resources {
+	constructor(tickFrequency, opnbFrequency, opmFrequency) {
+		checkTypes(arguments, "number", "number", "number");
+		this.tracks = [];
+		this.tickFrequency = tickFrequency;
+		this.opnbFrequency = opnbFrequency;
+		this.opmFrequency = opmFrequency;
+	}
+
+	addAudioTrack(track) {
+		checkTypes(arguments, AudioTrack);
+		this.tracks.push(track);
+	}
+
+	async write() {
+		return Promise.all([
+			Promise.all(this.tracks.map(async track => track.toVGMOPNB(this.opnbFrequency))),
+			Promise.all(this.tracks.map(async track => track.toVGMOPM(this.opmFrequency))),
+			Promise.all(this.tracks.map(async track => track.toGNG(this.tickFrequency, this.opnbFrequency, this.opmFrequency)))
+		]);
+	}
+}
+
+class ResourcesLoader {
+	constructor(path) {
+		checkTypes(arguments, "string");
+		this.path = path;
+	}
+
+	async parse() {
+		const json = JSON.parse(await fs.promises.readFile(this.path));
+
+		const resources = new Resources(json.tickFrequency, json.opnbFrequency, json.opmFrequency);
+
+		const tracks = Promise.all(json.tracks.map(async jsonTrack => {
+			const trackPath = path.resolve(path.dirname(this.path), jsonTrack.path);
+			const track = await new VGMLoader(jsonTrack.name, trackPath).loadTrack();
+			const waitThreshold = "waitThreshold" in jsonTrack ? jsonTrack.waitThreshold : json.waitThreshold || 0;
+			const channelMapping = "channelMapping" in jsonTrack ? jsonTrack.channelMapping : json.channelMapping || [0, 1, 2, 3, 4, 5, 6, 7];
+			return (track
+				.moveWaitAfterLoop()
+				.removeWaitsUnder(waitThreshold)
+				.toFMCommands()
+				.mapChannels(channelMapping)
+				.frequencyBeforeKeyOn()
+				.trimLeadingSilence()
+				// .quantise(60)
+			);
+		}));
+
+		for (const track of await tracks)
+			resources.addAudioTrack(track);
+
+		return resources;
+	}
+}
+
+exports.Resources = Resources;
+exports.ResourcesLoader = ResourcesLoader;
+
+main().catch(reason => { throw reason; });

          
A => tools/utils.js +35 -0
@@ 0,0 1,35 @@ 
+"use strict"
+
+function toAsmHex(value) {
+	const hex = value.toString(16).toUpperCase();
+	return hex.charCodeAt(0) > 64 ? `0${hex}H` : `${hex}H`;
+}
+
+/**
+ * Type checker, intended for use at the start of any function.
+ * Primitive types are checked by a string, objects by their constructor.
+ * Use an array to denote optionality. Example:
+ *
+ * function myFunction(aNumber, aFunction, anOptionalString) {
+ *     checkTypes(arguments, "number", Function, ["string"])
+ * }
+ *
+ * @param {Array} args An array-like list of arguments.
+ * @param {string|Function|Array} types... The types to check against.
+ */
+function checkTypes(args) {
+	for (let i = 1; i < arguments.length; i++) {
+		let type = arguments[i], arg = args[i - 1];
+		if (!(type instanceof Array && (type = type[0]) && arg == null) &&
+				!(typeof type == "string" && typeof arg == type && arg == arg) &&
+				!(type instanceof Function && arg instanceof type)) {
+			if (typeof type != "string" && !(type instanceof Function))
+				throw new Error(`Unsupported type ${type}.`);
+			throw new Error(`Argument of type ${typeof type != "string" && type.constructor.name || type} expected at index ${i - 1}. ` +
+					`Was: ${arg && typeof arg != "string" && arg.constructor && arg.constructor.name || arg}.`);
+		}
+	}
+}
+
+exports.checkTypes = checkTypes;
+exports.toAsmHex = toAsmHex;