From 762e8aff592fcdeb58568f066770a4af17a21809 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 27 Jul 2026 16:56:33 +0200 Subject: [PATCH 1/3] feat(music): pure-Go OPL2/OPL3 DMX music path (MUS/MIDI -> GENMIDI -> PCM) Implement the chocolate-doom OPL music path in pure Go (CGO=0), wiring synthesised music into the transpiled engine's existing music_module hook. New packages under music/: - mus: DMX MUS (D_* lumps) parser + mus2mid MUS->MIDI conversion, asserted BYTE-IDENTICAL to chocolate-doom's mus2mid.c compiled as an oracle over hand-authored synthetic MUS vectors. - midi: Standard MIDI File parser (port of midifile.c). - genmidi: GENMIDI lump parser (the DMX OPL instrument bank). - opl: Nuked OPL3 (Yamaha YMF262) emulator, validated BIT-EXACT against a Nuked-OPL3 C register->PCM trace. - oplplayer: MIDI + GENMIDI -> OPL register writes -> PCM (port of i_oplmusic.c), validated against a captured i_oplmusic.c register-write trace for D_INTRO (byte-identical, looping). Engine wiring (music_bridge.go / music_opl.go): initMusicModule() installs the OPL synth as the active music_module; the host frontend pulls stereo PCM through gore.ReadMusicPCM. i_RegisterSong now returns the module handle. The frame-determinism and audio-event oracles remain byte-equal after wiring (the 4-gate provable-test protocol is respected). go.mod floor -> 1.26.4. Coverage: mus/midi/genmidi/opl/oplplayer all 100%. Builds CGO=0 on all six 64-bit arches. Ports carry upstream SPDX headers (GPL-2.0-or-later for the chocolate-doom-derived code; LGPL-2.1-or-later for the Nuked OPL3 port). Testdata provenance/licensing documented in music/TESTDATA.md (Freedoom). Co-Authored-By: Claude Opus 4.8 --- README.md | 34 +- doom.go | 10 +- go.mod | 2 +- music/TESTDATA.md | 19 + music/genmidi/genmidi.go | 155 ++ music/genmidi/genmidi_test.go | 155 ++ music/genmidi/testdata/GENMIDI.lmp | Bin 0 -> 11908 bytes music/midi/midifile.go | 388 +++++ music/midi/midifile_test.go | 344 ++++ music/midi/testdata/D_INTRO.lmp | Bin 0 -> 2792 bytes music/mus/mus.go | 357 ++++ music/mus/mus_test.go | 177 ++ music/mus/testdata/allevents.mid | Bin 0 -> 203 bytes music/mus/testdata/allevents.mus | Bin 0 -> 102 bytes music/mus/testdata/basic.mid | Bin 0 -> 49 bytes music/mus/testdata/basic.mus | Bin 0 -> 29 bytes music/mus/testdata/bigdelay.mid | Bin 0 -> 40 bytes music/mus/testdata/bigdelay.mus | Bin 0 -> 23 bytes music/mus/testdata/ctrlhigh.mid | Bin 0 -> 38 bytes music/mus/testdata/ctrlhigh.mus | Bin 0 -> 21 bytes music/opl/opl3.go | 1303 ++++++++++++++ music/opl/opl3_internal_test.go | 156 ++ music/opl/opl3_test.go | 219 +++ music/opl/testdata/golden_buffered.pcm | Bin 0 -> 50496 bytes music/opl/testdata/golden_gen.pcm | Bin 0 -> 50496 bytes music/opl/testdata/golden_stream.pcm | Bin 0 -> 50496 bytes music/opl/wf_rom.go | 1052 +++++++++++ music/oplplayer/coverage_test.go | 400 +++++ music/oplplayer/oplplayer.go | 967 ++++++++++ music/oplplayer/oplplayer_test.go | 169 ++ music/oplplayer/queue.go | 109 ++ music/oplplayer/tables.go | 143 ++ music/oplplayer/testdata/regtrace_dintro.txt | 1648 ++++++++++++++++++ music_bridge.go | 14 + music_opl.go | 158 ++ 35 files changed, 7975 insertions(+), 4 deletions(-) create mode 100644 music/TESTDATA.md create mode 100644 music/genmidi/genmidi.go create mode 100644 music/genmidi/genmidi_test.go create mode 100644 music/genmidi/testdata/GENMIDI.lmp create mode 100644 music/midi/midifile.go create mode 100644 music/midi/midifile_test.go create mode 100644 music/midi/testdata/D_INTRO.lmp create mode 100644 music/mus/mus.go create mode 100644 music/mus/mus_test.go create mode 100644 music/mus/testdata/allevents.mid create mode 100644 music/mus/testdata/allevents.mus create mode 100644 music/mus/testdata/basic.mid create mode 100644 music/mus/testdata/basic.mus create mode 100644 music/mus/testdata/bigdelay.mid create mode 100644 music/mus/testdata/bigdelay.mus create mode 100644 music/mus/testdata/ctrlhigh.mid create mode 100644 music/mus/testdata/ctrlhigh.mus create mode 100644 music/opl/opl3.go create mode 100644 music/opl/opl3_internal_test.go create mode 100644 music/opl/opl3_test.go create mode 100644 music/opl/testdata/golden_buffered.pcm create mode 100644 music/opl/testdata/golden_gen.pcm create mode 100644 music/opl/testdata/golden_stream.pcm create mode 100644 music/opl/wf_rom.go create mode 100644 music/oplplayer/coverage_test.go create mode 100644 music/oplplayer/oplplayer.go create mode 100644 music/oplplayer/oplplayer_test.go create mode 100644 music/oplplayer/queue.go create mode 100644 music/oplplayer/tables.go create mode 100644 music/oplplayer/testdata/regtrace_dintro.txt create mode 100644 music_bridge.go create mode 100644 music_opl.go diff --git a/README.md b/README.md index 930b6b0..c3f7368 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,38 @@ examples are kept verbatim, so we can rebase against upstream cleanly. | Cross-compiles linux/arm64 | yes (CGO=0) | | Runs shareware DOOM1.WAD | yes (engine ticks; verified via TestMenus harness) | | TamaGo backend wired | scaffold only; real drivers land in follow-up sprint | -| Music (MUS -> MIDI) | out of scope for sprint 1 | -| Multiplayer | out of scope | +| Music (MUS/MIDI + OPL2/OPL3 synth)| yes -- pure-Go OPL path (see `music/`) | +| Multiplayer (netgame lockstep) | yes -- protocol + lockstep (see `netgame/`)| + +## Music and multiplayer (pure Go, CGO=0) + +The chocolate-doom-faithful music and netgame paths ship as standalone, +differentially-tested packages, all CGO-free and building on the six 64-bit +arches: + +| Package | Role | +|--------------------|-------------------------------------------------------------| +| `music/mus` | DMX MUS (`D_*` lumps) parse + `mus2mid` MUS->MIDI conversion | +| `music/midi` | Standard MIDI File parser (port of `midifile.c`) | +| `music/genmidi` | `GENMIDI` lump (the DMX OPL instrument bank) parser | +| `music/opl` | Nuked OPL3 (Yamaha YMF262) emulator -> PCM | +| `music/oplplayer` | MIDI + GENMIDI -> OPL register writes -> PCM (`i_oplmusic`) | +| `netgame` | classic doomcom/ticcmd lockstep protocol + transport seam | + +The engine installs the OPL synth as its `music_module` (see `music_opl.go`); +the host frontend pulls synthesised stereo PCM through `gore.ReadMusicPCM`. + +**Differential oracles.** `music/mus` conversion is asserted **byte-identical** +to chocolate-doom's `mus2mid.c` (compiled as an oracle over synthetic MUS +vectors). `music/opl` is validated **bit-exact** against a Nuked OPL3 C +register->PCM trace. `music/oplplayer` is checked against a captured +`i_oplmusic.c` **register-write trace** for `D_INTRO`. `netgame` runs two, three +and four in-process engines over an in-memory transport and asserts they stay in +**lockstep with byte-identical per-tic state hashes**; ticcmd serialisation is +byte-faithful to the engine's own vanilla layout. + +Music and multiplayer do not perturb the frame/audio determinism oracles: the +`oracle/` frames and audio-event log remain byte-equal after wiring. ## Quick smoke test (host) diff --git a/doom.go b/doom.go index 03e968f..a15eb93 100644 --- a/doom.go +++ b/doom.go @@ -17340,8 +17340,16 @@ func initSfxModule(use_sfx_prefix boolean) { } // Initialize music according to snd_musicdevice. +// +// The music module itself lives in music_bridge.go (pure-Go OPL2/OPL3 synth +// driving the DMX GENMIDI bank, matching chocolate-doom's OPL path). It is +// registered through installMusicModule so this transpiled entry point keeps +// a one-line hook and the synth stays out of the generated blob. func initMusicModule() { + if installMusicModule != nil { + installMusicModule() + } } // @@ -17503,7 +17511,7 @@ func i_ResumeSong() { func i_RegisterSong(data []byte) uintptr { if music_module != nil { - music_module.FRegisterSong(data) + return music_module.FRegisterSong(data) } return 0 } diff --git a/go.mod b/go.mod index 03ccca7..c06a156 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/go-doom/engine -go 1.26.0 +go 1.26.4 require ( github.com/hajimehoshi/ebiten/v2 v2.9.9 diff --git a/music/TESTDATA.md b/music/TESTDATA.md new file mode 100644 index 0000000..c0017e3 --- /dev/null +++ b/music/TESTDATA.md @@ -0,0 +1,19 @@ +# Test-data provenance and licensing + +The music packages ship small binary fixtures under `*/testdata/`. Their +provenance and license: + +| File(s) | Origin | License | +|-------------------------------------------|------------------------------------------------------------------------|---------| +| `mus/testdata/*.mus` | Hand-authored synthetic MUS vectors (this project) | BSD-3-Clause | +| `mus/testdata/*.mid` | Mechanical output of chocolate-doom `mus2mid.c` on the `.mus` vectors (the differential oracle) | derived / functional | +| `opl/testdata/*.pcm` | PCM captured from the Nuked OPL3 C emulator over a synthetic register script | derived / functional | +| `genmidi/testdata/GENMIDI.lmp` | `GENMIDI` lump from **Freedoom Phase 1** (`freedoom1.wad`) | BSD-3-Clause equivalent (Freedoom) | +| `midi/testdata/D_INTRO.lmp` | `D_INTRO` music lump from **Freedoom Phase 1** (`freedoom1.wad`) | BSD-3-Clause equivalent (Freedoom) | +| `oplplayer/testdata/regtrace_dintro.txt` | OPL register-write trace of chocolate-doom `i_oplmusic.c` playing the Freedoom `D_INTRO` (the differential oracle) | derived / functional | + +The `GENMIDI.lmp` and `D_INTRO.lmp` fixtures come from +[Freedoom](https://freedoom.github.io/), a free/libre content replacement +released under a BSD-3-Clause-equivalent license, so they are freely +redistributable. No copyrighted id Software WAD data is committed; the engine's +own WAD blob remains git-ignored (see `embedwad/.gitignore`). diff --git a/music/genmidi/genmidi.go b/music/genmidi/genmidi.go new file mode 100644 index 0000000..0c842ff --- /dev/null +++ b/music/genmidi/genmidi.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom midifile.c / GENMIDI handling (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +// Package genmidi parses the DMX GENMIDI lump, the OPL instrument bank used by +// DOOM's Adlib/OPL music playback. The layout is a faithful port of the +// genmidi_instr_t table read by chocolate-doom's i_oplmusic.c +// LoadInstrumentTable. +package genmidi + +import ( + "encoding/binary" + "errors" +) + +// Header is the 8-byte GENMIDI magic ("#OPL_II#"). +const Header = "#OPL_II#" + +// Instrument counts, matching i_oplmusic.c. +const ( + NumInstruments = 128 // GENMIDI_NUM_INSTRS: melodic instruments + NumPercussion = 47 // GENMIDI_NUM_PERCUSSION + // NumEntries is the total number of instrument entries in the lump, the + // melodic instruments followed by the percussion instruments. + NumEntries = NumInstruments + NumPercussion // 175 +) + +// Instrument flag bits (genmidi_instr_t.flags). +const ( + FlagFixed = 0x0001 // fixed pitch + Flag2Voice = 0x0004 // double voice (OPL3) +) + +// Byte sizes of the packed on-disk structures. +const ( + operatorSize = 6 // genmidi_op_t + voiceSize = operatorSize + 1 + operatorSize + 1 + 2 // genmidi_voice_t = 16 + instrumentSize = 2 + 1 + 1 + 2*voiceSize // genmidi_instr_t = 36 + nameSize = 32 +) + +// Operator holds the six per-operator bytes of a genmidi_op_t. +type Operator struct { + TremoloVibrato uint8 // tremolo + AttackDecay uint8 // attack + SustainRelease uint8 // sustain + Waveform uint8 // waveform + Scale uint8 // scale + Level uint8 // level +} + +// Voice is a genmidi_voice_t: a modulator/carrier operator pair plus the +// feedback byte, an unused byte and the signed base note offset. +type Voice struct { + Modulator Operator + Feedback uint8 + Carrier Operator + Unused uint8 + BaseNoteOffset int16 +} + +// Instrument is a genmidi_instr_t: flags, tuning and two voices. +type Instrument struct { + Flags uint16 + FineTuning uint8 + FixedNote uint8 + Voices [2]Voice +} + +// Bank is a parsed GENMIDI lump. Instruments 0..127 are the melodic +// instruments; 128..174 are the percussion instruments. Names, when present in +// the lump, are the trailing 32-byte name strings in the same order. +type Bank struct { + Instruments [NumEntries]Instrument + // Names holds the trailing 32-byte name strings if the lump includes + // them; it is nil when the lump is truncated to just the instrument data + // (both layouts are accepted, following DMX/i_oplmusic behaviour). + Names [][nameSize]byte +} + +// Errors returned by Load. +var ( + ErrBadHeader = errors.New("genmidi: missing '#OPL_II#' header") + ErrShort = errors.New("genmidi: lump too short for instrument table") +) + +// Load parses a GENMIDI lump. It requires the 8-byte header and the 175 +// instrument entries. The trailing name block is parsed when present but is +// optional, matching the two GENMIDI layouts seen in the wild. +func Load(b []byte) (*Bank, error) { + if len(b) < len(Header) { + return nil, ErrShort + } + if string(b[:len(Header)]) != Header { + return nil, ErrBadHeader + } + + off := len(Header) + need := off + NumEntries*instrumentSize + if len(b) < need { + return nil, ErrShort + } + + bank := &Bank{} + for i := 0; i < NumEntries; i++ { + bank.Instruments[i] = parseInstrument(b[off : off+instrumentSize]) + off += instrumentSize + } + + // Trailing names are optional. Parse as many complete 32-byte names as the + // lump provides (up to one per entry). + if len(b) >= off+nameSize { + names := make([][nameSize]byte, 0, NumEntries) + for i := 0; i < NumEntries && len(b) >= off+nameSize; i++ { + var n [nameSize]byte + copy(n[:], b[off:off+nameSize]) + names = append(names, n) + off += nameSize + } + bank.Names = names + } + + return bank, nil +} + +func parseInstrument(b []byte) Instrument { + var instr Instrument + instr.Flags = binary.LittleEndian.Uint16(b[0:2]) + instr.FineTuning = b[2] + instr.FixedNote = b[3] + instr.Voices[0] = parseVoice(b[4 : 4+voiceSize]) + instr.Voices[1] = parseVoice(b[4+voiceSize : 4+2*voiceSize]) + return instr +} + +func parseVoice(b []byte) Voice { + var v Voice + v.Modulator = parseOperator(b[0:operatorSize]) + v.Feedback = b[operatorSize] + v.Carrier = parseOperator(b[operatorSize+1 : 2*operatorSize+1]) + v.Unused = b[2*operatorSize+1] + v.BaseNoteOffset = int16(binary.LittleEndian.Uint16(b[2*operatorSize+2 : 2*operatorSize+4])) + return v +} + +func parseOperator(b []byte) Operator { + return Operator{ + TremoloVibrato: b[0], + AttackDecay: b[1], + SustainRelease: b[2], + Waveform: b[3], + Scale: b[4], + Level: b[5], + } +} diff --git a/music/genmidi/genmidi_test.go b/music/genmidi/genmidi_test.go new file mode 100644 index 0000000..282f0e5 --- /dev/null +++ b/music/genmidi/genmidi_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom midifile.c / GENMIDI handling (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +package genmidi + +import ( + "os" + "path/filepath" + "testing" +) + +func loadTestBank(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "GENMIDI.lmp")) + if err != nil { + t.Fatalf("read testdata: %v", err) + } + return b +} + +func TestLoadRealLump(t *testing.T) { + b := loadTestBank(t) + if len(b) != 11908 { + t.Fatalf("unexpected lump size %d", len(b)) + } + + bank, err := Load(b) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // 175 instrument entries (128 melodic + 47 percussion). + if NumEntries != 175 { + t.Fatalf("NumEntries constant = %d, want 175", NumEntries) + } + if got := len(bank.Instruments); got != NumEntries { + t.Fatalf("len(Instruments) = %d, want %d", got, NumEntries) + } + + // Spot-check the first instrument (Acoustic Grand Piano). + in := bank.Instruments[0] + if in.Flags != 0x0004 { + t.Errorf("Flags = %#x, want 0x0004", in.Flags) + } + if in.FineTuning != 0x82 { + t.Errorf("FineTuning = %#x, want 0x82", in.FineTuning) + } + if in.FixedNote != 0x00 { + t.Errorf("FixedNote = %#x, want 0x00", in.FixedNote) + } + + m := in.Voices[0].Modulator + wantMod := Operator{0x33, 0xe1, 0x23, 0x02, 0x80, 0x25} + if m != wantMod { + t.Errorf("Voice0 modulator = %+v, want %+v", m, wantMod) + } + if in.Voices[0].Feedback != 0x0e { + t.Errorf("Voice0 feedback = %#x, want 0x0e", in.Voices[0].Feedback) + } + c := in.Voices[0].Carrier + wantCar := Operator{0x31, 0xf1, 0xf4, 0x04, 0x00, 0x09} + if c != wantCar { + t.Errorf("Voice0 carrier = %+v, want %+v", c, wantCar) + } + if in.Voices[0].BaseNoteOffset != -12 { + t.Errorf("Voice0 base note offset = %d, want -12", in.Voices[0].BaseNoteOffset) + } + if in.Voices[1].BaseNoteOffset != -12 { + t.Errorf("Voice1 base note offset = %d, want -12", in.Voices[1].BaseNoteOffset) + } + + // The 2VOICE flag is set on this instrument. + if in.Flags&Flag2Voice == 0 { + t.Errorf("expected Flag2Voice set on instrument 0") + } + + // Names present: first name is "Acoustic Grand Piano". + if len(bank.Names) != NumEntries { + t.Fatalf("len(Names) = %d, want %d", len(bank.Names), NumEntries) + } + name := nameString(bank.Names[0]) + if name != "Acoustic Grand Piano" { + t.Errorf("Names[0] = %q, want %q", name, "Acoustic Grand Piano") + } +} + +func nameString(n [nameSize]byte) string { + end := 0 + for end < len(n) && n[end] != 0 { + end++ + } + return string(n[:end]) +} + +func TestLoadWithoutNames(t *testing.T) { + full := loadTestBank(t) + // Truncate to header + instrument table only (no trailing names). + trimmed := make([]byte, len(Header)+NumEntries*instrumentSize) + copy(trimmed, full) + + bank, err := Load(trimmed) + if err != nil { + t.Fatalf("Load (no names): %v", err) + } + if bank.Names != nil { + t.Errorf("expected nil Names for name-less lump, got %d", len(bank.Names)) + } + if bank.Instruments[0].Flags != 0x0004 { + t.Errorf("instrument 0 flags wrong after trim") + } +} + +func TestLoadPartialNames(t *testing.T) { + full := loadTestBank(t) + // Keep the instrument table plus exactly two complete names. + base := len(Header) + NumEntries*instrumentSize + trimmed := make([]byte, base+2*nameSize) + copy(trimmed, full) + + bank, err := Load(trimmed) + if err != nil { + t.Fatalf("Load (partial names): %v", err) + } + if len(bank.Names) != 2 { + t.Fatalf("len(Names) = %d, want 2", len(bank.Names)) + } +} + +func TestLoadErrors(t *testing.T) { + full := loadTestBank(t) + + t.Run("too short for header", func(t *testing.T) { + if _, err := Load([]byte("#OPL")); err != ErrShort { + t.Fatalf("err = %v, want ErrShort", err) + } + }) + + t.Run("bad header", func(t *testing.T) { + bad := make([]byte, len(full)) + copy(bad, full) + bad[0] = 'X' + if _, err := Load(bad); err != ErrBadHeader { + t.Fatalf("err = %v, want ErrBadHeader", err) + } + }) + + t.Run("short instrument table", func(t *testing.T) { + short := make([]byte, len(Header)+10) + copy(short, full) + if _, err := Load(short); err != ErrShort { + t.Fatalf("err = %v, want ErrShort", err) + } + }) +} diff --git a/music/genmidi/testdata/GENMIDI.lmp b/music/genmidi/testdata/GENMIDI.lmp new file mode 100644 index 0000000000000000000000000000000000000000..a931371f62f22611ae9d79dc9e07f30fa215aff2 GIT binary patch literal 11908 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 RLrMHwcEB>gZ2c1S{|9!W>;nJ* literal 0 HcmV?d00001 diff --git a/music/midi/midifile.go b/music/midi/midifile.go new file mode 100644 index 0000000..fc1c522 --- /dev/null +++ b/music/midi/midifile.go @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom midifile.c / GENMIDI handling (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +// Package midi parses Standard MIDI Files (type 0 and 1) into tracks of +// timestamped events. It is a faithful Go port of chocolate-doom's +// midifile.c, preserving its handling of variable-length quantities, +// running status, meta events and SysEx events. +package midi + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// EventType identifies the kind of a MIDI event. The values match the status +// nibble/byte used on the wire (see midifile.h). +type EventType uint8 + +const ( + NoteOff EventType = 0x80 + NoteOn EventType = 0x90 + Aftertouch EventType = 0xA0 + Controller EventType = 0xB0 + ProgramChange EventType = 0xC0 + ChanAftertouch EventType = 0xD0 + PitchBend EventType = 0xE0 + + SysEx EventType = 0xF0 + SysExSplit EventType = 0xF7 + Meta EventType = 0xFF +) + +// Meta event sub-types (the value stored in Event.MetaType when Type == Meta). +const ( + MetaSequenceNumber = 0x00 + MetaText = 0x01 + MetaCopyright = 0x02 + MetaTrackName = 0x03 + MetaInstrName = 0x04 + MetaLyrics = 0x05 + MetaMarker = 0x06 + MetaCuePoint = 0x07 + MetaChannelPrefix = 0x20 + MetaEndOfTrack = 0x2F + MetaSetTempo = 0x51 + MetaSMPTEOffset = 0x54 + MetaTimeSignature = 0x58 + MetaKeySignature = 0x59 + MetaSequencerSpecial = 0x7F +) + +// Event is a single MIDI event with the delta time that precedes it. +// +// For channel events (NoteOff..PitchBend) Channel, Param1 and (where +// applicable) Param2 are populated. For SysEx / SysExSplit events Data holds +// the raw payload. For Meta events MetaType identifies the sub-type and Data +// holds the payload. +type Event struct { + DeltaTime uint32 + Type EventType + + // Channel event data. + Channel uint8 + Param1 uint8 + Param2 uint8 + + // Meta event data. + MetaType uint8 + + // SysEx / Meta payload. + Data []byte +} + +// File is a parsed Standard MIDI File. +type File struct { + // FormatType is the SMF format (0 or 1). + FormatType uint16 + // TimeDivision is the raw time-division field from the header (ticks per + // quarter note, or SMPTE-encoded when the high bit is set). Use + // TimeDivisionTicks for the resolved tick value. + TimeDivision uint16 + // Tracks holds the events of each track in file order. + Tracks [][]Event +} + +// Errors returned by Parse. +var ( + ErrShortHeader = errors.New("midi: unexpected end of data in header") + ErrBadHeaderID = errors.New("midi: expected 'MThd' chunk header") + ErrBadHeaderSize = errors.New("midi: invalid MIDI header chunk size") + ErrUnsupportedFmt = errors.New("midi: only type 0/1 MIDI files supported") + ErrNoTracks = errors.New("midi: file contains no tracks") + ErrBadTrackID = errors.New("midi: expected 'MTrk' chunk header") + ErrShortTrack = errors.New("midi: unexpected end of data in track") + ErrVarLenTooLong = errors.New("midi: variable-length value too long (max four bytes)") +) + +const ( + headerChunkID = "MThd" + trackChunkID = "MTrk" +) + +// reader is a bounded cursor over a byte slice. +type reader struct { + data []byte + pos int +} + +func (r *reader) remaining() int { return len(r.data) - r.pos } + +// readByte reads a single byte, returning ErrShortTrack on EOF. +func (r *reader) readByte() (byte, error) { + if r.pos >= len(r.data) { + return 0, ErrShortTrack + } + b := r.data[r.pos] + r.pos++ + return b, nil +} + +// readVariableLength reads a MIDI variable-length quantity (up to four bytes, +// seven significant bits each). +func (r *reader) readVariableLength() (uint32, error) { + var result uint32 + for i := 0; i < 4; i++ { + b, err := r.readByte() + if err != nil { + return 0, err + } + result <<= 7 + result |= uint32(b & 0x7f) + if b&0x80 == 0 { + return result, nil + } + } + return 0, ErrVarLenTooLong +} + +// readByteSequence reads num bytes into a fresh slice. +func (r *reader) readByteSequence(num uint32) ([]byte, error) { + if uint64(num) > uint64(r.remaining()) { + return nil, ErrShortTrack + } + out := make([]byte, num) + copy(out, r.data[r.pos:r.pos+int(num)]) + r.pos += int(num) + return out, nil +} + +// Parse parses a Standard MIDI File from b. +func Parse(b []byte) (*File, error) { + r := &reader{data: b} + + f := &File{} + numTracks, err := readFileHeader(r, f) + if err != nil { + return nil, err + } + + f.Tracks = make([][]Event, 0, numTracks) + for i := 0; i < numTracks; i++ { + track, err := readTrack(r) + if err != nil { + return nil, err + } + f.Tracks = append(f.Tracks, track) + } + + return f, nil +} + +// readFileHeader parses the MThd chunk and returns the declared track count. +func readFileHeader(r *reader, f *File) (int, error) { + // midi_header_t: 4 id + 4 size + 2 format + 2 tracks + 2 division = 14. + if r.remaining() < 14 { + return 0, ErrShortHeader + } + id := string(r.data[r.pos : r.pos+4]) + if id != headerChunkID { + return 0, fmt.Errorf("%w, got %q", ErrBadHeaderID, id) + } + chunkSize := binary.BigEndian.Uint32(r.data[r.pos+4 : r.pos+8]) + if chunkSize != 6 { + return 0, fmt.Errorf("%w: chunk_size=%d", ErrBadHeaderSize, chunkSize) + } + f.FormatType = binary.BigEndian.Uint16(r.data[r.pos+8 : r.pos+10]) + numTracks := binary.BigEndian.Uint16(r.data[r.pos+10 : r.pos+12]) + f.TimeDivision = binary.BigEndian.Uint16(r.data[r.pos+12 : r.pos+14]) + r.pos += 14 + + if f.FormatType != 0 && f.FormatType != 1 { + return 0, ErrUnsupportedFmt + } + if numTracks < 1 { + return 0, ErrNoTracks + } + return int(numTracks), nil +} + +// readTrack parses one MTrk chunk, reading events until END_OF_TRACK. +func readTrack(r *reader) ([]Event, error) { + if r.remaining() < 8 { + return nil, ErrShortTrack + } + id := string(r.data[r.pos : r.pos+4]) + if id != trackChunkID { + return nil, fmt.Errorf("%w, got %q", ErrBadTrackID, id) + } + // data_len is read (and swapped BE) by the C but the parser relies on the + // END_OF_TRACK meta event to delimit the track, so we do the same. + r.pos += 8 + + var events []Event + var lastEventType byte + for { + ev, err := readEvent(r, &lastEventType) + if err != nil { + return nil, err + } + events = append(events, ev) + if ev.Type == Meta && ev.MetaType == MetaEndOfTrack { + break + } + } + return events, nil +} + +// readEvent reads a single event, honouring running status via lastEventType. +func readEvent(r *reader, lastEventType *byte) (Event, error) { + var ev Event + + delta, err := r.readVariableLength() + if err != nil { + return ev, err + } + ev.DeltaTime = delta + + eventType, err := r.readByte() + if err != nil { + return ev, err + } + + // If the top bit is not set we are using running status: reuse the + // previous event type and rewind so the byte is re-read as a parameter. + if eventType&0x80 == 0 { + eventType = *lastEventType + r.pos-- + } else { + *lastEventType = eventType + } + + switch eventType & 0xf0 { + case byte(NoteOff), byte(NoteOn), byte(Aftertouch), byte(Controller), byte(PitchBend): + return readChannelEvent(r, &ev, eventType, true) + case byte(ProgramChange), byte(ChanAftertouch): + return readChannelEvent(r, &ev, eventType, false) + } + + switch eventType { + case byte(SysEx), byte(SysExSplit): + return readSysExEvent(r, &ev, eventType) + case byte(Meta): + return readMetaEvent(r, &ev) + } + + return ev, fmt.Errorf("midi: unknown MIDI event type: 0x%x", eventType) +} + +// readChannelEvent reads a one- or two-parameter channel voice event. +func readChannelEvent(r *reader, ev *Event, eventType byte, twoParam bool) (Event, error) { + ev.Type = EventType(eventType & 0xf0) + ev.Channel = eventType & 0x0f + + b, err := r.readByte() + if err != nil { + return *ev, err + } + ev.Param1 = b + + if twoParam { + b, err = r.readByte() + if err != nil { + return *ev, err + } + ev.Param2 = b + } + return *ev, nil +} + +// readSysExEvent reads a SysEx (0xF0) or split SysEx (0xF7) event. +func readSysExEvent(r *reader, ev *Event, eventType byte) (Event, error) { + ev.Type = EventType(eventType) + length, err := r.readVariableLength() + if err != nil { + return *ev, err + } + data, err := r.readByteSequence(length) + if err != nil { + return *ev, err + } + ev.Data = data + return *ev, nil +} + +// readMetaEvent reads a meta event (0xFF). +func readMetaEvent(r *reader, ev *Event) (Event, error) { + ev.Type = Meta + b, err := r.readByte() + if err != nil { + return *ev, err + } + ev.MetaType = b + + length, err := r.readVariableLength() + if err != nil { + return *ev, err + } + data, err := r.readByteSequence(length) + if err != nil { + return *ev, err + } + ev.Data = data + return *ev, nil +} + +// NumTracks returns the number of tracks in the file. +func (f *File) NumTracks() int { return len(f.Tracks) } + +// TimeDivisionTicks resolves the header time-division field to a positive tick +// value, handling SMPTE-encoded (negative) divisions like +// MIDI_GetFileTimeDivision. +func (f *File) TimeDivisionTicks() int { + result := int16(f.TimeDivision) + if result < 0 { + return int(-(result / 256)) * int(result&0xFF) + } + return int(result) +} + +// TrackIterator iterates the events of a single track, mirroring +// midi_track_iter_t / MIDI_GetNextEvent semantics. +type TrackIterator struct { + events []Event + position int + loopPoint int +} + +// IterateTrack returns an iterator over the events of the given track. It +// panics if track is out of range, matching the C assert. +func (f *File) IterateTrack(track int) *TrackIterator { + if track < 0 || track >= len(f.Tracks) { + panic(fmt.Sprintf("midi: track %d out of range (%d tracks)", track, len(f.Tracks))) + } + return &TrackIterator{events: f.Tracks[track]} +} + +// DeltaTime returns the delta time of the next event, or 0 at end of track. +func (it *TrackIterator) DeltaTime() uint32 { + if it.position < len(it.events) { + return it.events[it.position].DeltaTime + } + return 0 +} + +// Next returns the next event and advances the iterator. The bool is false +// once the end of the track is reached. +func (it *TrackIterator) Next() (*Event, bool) { + if it.position < len(it.events) { + ev := &it.events[it.position] + it.position++ + return ev, true + } + return nil, false +} + +// Restart resets the iterator to the beginning of the track. +func (it *TrackIterator) Restart() { + it.position = 0 + it.loopPoint = 0 +} + +// SetLoopPoint marks the current position as the loop point. +func (it *TrackIterator) SetLoopPoint() { it.loopPoint = it.position } + +// RestartAtLoopPoint rewinds the iterator to the saved loop point. +func (it *TrackIterator) RestartAtLoopPoint() { it.position = it.loopPoint } diff --git a/music/midi/midifile_test.go b/music/midi/midifile_test.go new file mode 100644 index 0000000..f8698b8 --- /dev/null +++ b/music/midi/midifile_test.go @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom midifile.c / GENMIDI handling (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +package midi + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +// buildHeader returns a 14-byte MThd chunk. +func buildHeader(format, numTracks, division uint16) []byte { + h := make([]byte, 14) + copy(h, headerChunkID) + binary.BigEndian.PutUint32(h[4:8], 6) + binary.BigEndian.PutUint16(h[8:10], format) + binary.BigEndian.PutUint16(h[10:12], numTracks) + binary.BigEndian.PutUint16(h[12:14], division) + return h +} + +// buildTrack wraps event bytes in an MTrk chunk. +func buildTrack(events []byte) []byte { + t := make([]byte, 8+len(events)) + copy(t, trackChunkID) + binary.BigEndian.PutUint32(t[4:8], uint32(len(events))) + copy(t[8:], events) + return t +} + +var endOfTrack = []byte{0x00, 0xFF, 0x2F, 0x00} + +func TestParseRealLump(t *testing.T) { + b, err := os.ReadFile(filepath.Join("testdata", "D_INTRO.lmp")) + if err != nil { + t.Fatalf("read testdata: %v", err) + } + + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if f.FormatType != 1 { + t.Errorf("FormatType = %d, want 1", f.FormatType) + } + if f.TimeDivision != 0x60 { + t.Errorf("TimeDivision = %#x, want 0x60", f.TimeDivision) + } + if f.TimeDivisionTicks() != 96 { + t.Errorf("TimeDivisionTicks = %d, want 96", f.TimeDivisionTicks()) + } + if f.NumTracks() != 9 { + t.Errorf("NumTracks = %d, want 9", f.NumTracks()) + } + + // Iterate the first track and confirm we reach END_OF_TRACK. + it := f.IterateTrack(0) + sawEOT := false + count := 0 + for { + ev, ok := it.Next() + if !ok { + break + } + count++ + if ev.Type == Meta && ev.MetaType == MetaEndOfTrack { + sawEOT = true + } + } + if !sawEOT { + t.Errorf("track 0 never reached END_OF_TRACK") + } + if count == 0 { + t.Errorf("track 0 had no events") + } +} + +func TestParseSyntheticEvents(t *testing.T) { + events := []byte{ + 0x00, 0x90, 0x3C, 0x40, // note on, ch0, note 60, vel 64 + 0x10, 0x3C, 0x00, // running status: note on (vel 0) + 0x00, 0xC0, 0x05, // program change, ch0, prog 5 + 0x00, 0xD0, 0x7F, // channel aftertouch (one param) + 0x00, 0xE0, 0x00, 0x40, // pitch bend (two param) + 0x00, 0xF0, 0x02, 0x11, 0x22, // sysex, len 2 + 0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20, // set tempo + } + events = append(events, endOfTrack...) + + b := append(buildHeader(0, 1, 96), buildTrack(events)...) + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + tr := f.Tracks[0] + // note on, running-status note on, prog change, chan aftertouch, + // pitch bend, sysex, set tempo, end of track = 8 events. + if len(tr) != 8 { + t.Fatalf("len(track) = %d, want 8", len(tr)) + } + + if tr[0].Type != NoteOn || tr[0].Channel != 0 || tr[0].Param1 != 60 || tr[0].Param2 != 64 { + t.Errorf("event0 = %+v", tr[0]) + } + // Running status: delta 0x10, reuses NoteOn. + if tr[1].Type != NoteOn || tr[1].DeltaTime != 0x10 || tr[1].Param1 != 60 || tr[1].Param2 != 0 { + t.Errorf("event1 (running status) = %+v", tr[1]) + } + if tr[2].Type != ProgramChange || tr[2].Param1 != 5 { + t.Errorf("event2 = %+v", tr[2]) + } + if tr[3].Type != ChanAftertouch || tr[3].Param1 != 0x7F { + t.Errorf("event3 = %+v", tr[3]) + } + if tr[4].Type != PitchBend || tr[4].Param1 != 0x00 || tr[4].Param2 != 0x40 { + t.Errorf("event4 = %+v", tr[4]) + } + if tr[5].Type != SysEx || len(tr[5].Data) != 2 || tr[5].Data[0] != 0x11 || tr[5].Data[1] != 0x22 { + t.Errorf("event5 (sysex) = %+v", tr[5]) + } + if tr[6].Type != Meta || tr[6].MetaType != MetaSetTempo || len(tr[6].Data) != 3 { + t.Errorf("event6 (set tempo) = %+v", tr[6]) + } + if tr[7].Type != Meta || tr[7].MetaType != MetaEndOfTrack { + t.Errorf("event7 = %+v", tr[7]) + } +} + +func TestParseSysExSplit(t *testing.T) { + events := []byte{0x00, 0xF7, 0x01, 0x55} + events = append(events, endOfTrack...) + b := append(buildHeader(0, 1, 96), buildTrack(events)...) + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if f.Tracks[0][0].Type != SysExSplit || f.Tracks[0][0].Data[0] != 0x55 { + t.Errorf("split sysex parsed wrong: %+v", f.Tracks[0][0]) + } +} + +func TestParseAftertouchAndController(t *testing.T) { + events := []byte{ + 0x00, 0xA0, 0x3C, 0x10, // aftertouch + 0x00, 0xB0, 0x07, 0x64, // controller (volume) + } + events = append(events, endOfTrack...) + b := append(buildHeader(0, 1, 96), buildTrack(events)...) + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if f.Tracks[0][0].Type != Aftertouch || f.Tracks[0][1].Type != Controller { + t.Errorf("aftertouch/controller parsed wrong") + } +} + +func TestVariableLengthMultiByte(t *testing.T) { + // Three-byte VLQ 0xBF 0xFF 0x7F = 0x3F<<14 | 0x7F<<7 | 0x7F = 0x0FFFFF. + events := []byte{0xBF, 0xFF, 0x7F, 0x90, 0x3C, 0x40} + events = append(events, endOfTrack...) + b := append(buildHeader(0, 1, 96), buildTrack(events)...) + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if f.Tracks[0][0].DeltaTime != 0x0FFFFF { + t.Errorf("DeltaTime = %#x, want 0x0FFFFF", f.Tracks[0][0].DeltaTime) + } +} + +func TestSMPTETimeDivision(t *testing.T) { + // SMPTE-encoded division 0xE728 (int16 = -6360). Faithful to + // MIDI_GetFileTimeDivision: -(result/256) * (result & 0xFF) with C-style + // truncating division = -(-24) * 40 = 960. + f := &File{TimeDivision: 0xE728} + if got := f.TimeDivisionTicks(); got != 960 { + t.Errorf("SMPTE TimeDivisionTicks = %d, want 960", got) + } +} + +func TestIterator(t *testing.T) { + events := []byte{ + 0x05, 0x90, 0x3C, 0x40, + } + events = append(events, endOfTrack...) + b := append(buildHeader(0, 1, 96), buildTrack(events)...) + f, err := Parse(b) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + it := f.IterateTrack(0) + if it.DeltaTime() != 0x05 { + t.Errorf("DeltaTime = %d, want 5", it.DeltaTime()) + } + ev, ok := it.Next() + if !ok || ev.Type != NoteOn { + t.Fatalf("first Next: ok=%v ev=%+v", ok, ev) + } + + it.SetLoopPoint() + ev, ok = it.Next() // end of track + if !ok || ev.MetaType != MetaEndOfTrack { + t.Fatalf("second Next: ok=%v ev=%+v", ok, ev) + } + // Exhausted. + if _, ok = it.Next(); ok { + t.Errorf("expected iterator exhausted") + } + if it.DeltaTime() != 0 { + t.Errorf("DeltaTime at end = %d, want 0", it.DeltaTime()) + } + + it.RestartAtLoopPoint() + ev, ok = it.Next() + if !ok || ev.MetaType != MetaEndOfTrack { + t.Errorf("after RestartAtLoopPoint: ok=%v ev=%+v", ok, ev) + } + + it.Restart() + if it.DeltaTime() != 0x05 { + t.Errorf("after Restart DeltaTime = %d, want 5", it.DeltaTime()) + } +} + +func TestIterateTrackPanic(t *testing.T) { + f := &File{Tracks: make([][]Event, 1)} + defer func() { + if recover() == nil { + t.Errorf("expected panic for out-of-range track") + } + }() + f.IterateTrack(5) +} + +func TestParseHeaderErrors(t *testing.T) { + valid := buildHeader(0, 1, 96) + + tests := []struct { + name string + b []byte + want error + }{ + {"short", valid[:10], ErrShortHeader}, + {"bad id", func() []byte { b := clone(valid); b[0] = 'X'; return b }(), ErrBadHeaderID}, + {"bad size", func() []byte { b := clone(valid); binary.BigEndian.PutUint32(b[4:8], 7); return b }(), ErrBadHeaderSize}, + {"bad format", func() []byte { b := clone(valid); binary.BigEndian.PutUint16(b[8:10], 2); return b }(), ErrUnsupportedFmt}, + {"no tracks", func() []byte { b := clone(valid); binary.BigEndian.PutUint16(b[10:12], 0); return b }(), ErrNoTracks}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.b) + if !errorIs(err, tt.want) { + t.Fatalf("err = %v, want %v", err, tt.want) + } + }) + } +} + +func TestParseTrackErrors(t *testing.T) { + hdr := buildHeader(0, 1, 96) + + t.Run("short track header", func(t *testing.T) { + b := append(clone(hdr), []byte{'M', 'T'}...) + if _, err := Parse(b); err != ErrShortTrack { + t.Fatalf("err = %v, want ErrShortTrack", err) + } + }) + + t.Run("bad track id", func(t *testing.T) { + b := append(clone(hdr), buildTrack(endOfTrack)...) + b[len(hdr)] = 'X' + if _, err := Parse(b); !errorIs(err, ErrBadTrackID) { + t.Fatalf("err = %v, want ErrBadTrackID", err) + } + }) + + t.Run("truncated before end of track", func(t *testing.T) { + // Track that starts an event but runs out of data. + b := append(clone(hdr), buildTrack([]byte{0x00, 0x90, 0x3C})...) + if _, err := Parse(b); err != ErrShortTrack { + t.Fatalf("err = %v, want ErrShortTrack", err) + } + }) + + t.Run("unknown event type", func(t *testing.T) { + b := append(clone(hdr), buildTrack([]byte{0x00, 0xF5, 0x00})...) + if _, err := Parse(b); err == nil { + t.Fatalf("expected error for unknown event type") + } + }) +} + +func TestReadEventErrorPaths(t *testing.T) { + hdr := buildHeader(0, 1, 96) + + cases := map[string][]byte{ + "delta eof": {}, // no bytes at all + "event type eof": {0x00}, // delta only + "channel param1 eof": {0x00, 0x90}, // status but no params + "channel param2 eof": {0x00, 0x90, 0x3C}, // missing param2 + "sysex length eof": {0x00, 0xF0}, // no varlen + "sysex data eof": {0x00, 0xF0, 0x04, 0x11}, // len 4, only 1 byte + "meta type eof": {0x00, 0xFF}, // no meta type + "meta length eof": {0x00, 0xFF, 0x51}, // no varlen + "meta data eof": {0x00, 0xFF, 0x51, 0x04}, // len 4, no data + "varlen too long": {0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // 5 continuation bytes + } + for name, ev := range cases { + t.Run(name, func(t *testing.T) { + b := append(clone(hdr), buildTrack(ev)...) + if _, err := Parse(b); err == nil { + t.Fatalf("expected error for %q", name) + } + }) + } +} + +func clone(b []byte) []byte { + c := make([]byte, len(b)) + copy(c, b) + return c +} + +// errorIs reports whether err wraps target (small helper to avoid importing +// errors just for this in the test). +func errorIs(err, target error) bool { + for err != nil { + if err == target { + return true + } + u, ok := err.(interface{ Unwrap() error }) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} diff --git a/music/midi/testdata/D_INTRO.lmp b/music/midi/testdata/D_INTRO.lmp new file mode 100644 index 0000000000000000000000000000000000000000..d67425cb4597d82bc71f9037ecad4db937638861 GIT binary patch literal 2792 zcmbtUOK%!i6h0)8N$pg2H`Q!hjWBYGknvawLXoO!s1dz_PKsq$A}xf6hZI_;gMg~) zs=TsbhfTcjw!eVqf?c1B{)TS)T=XAwQ@P)r2Qwrwj_k$Nz2`gM`R;klT;-ss644U5 z=rzhJ2bIrZ-J$d3ty?#Q+jPE7CbmN7dpDQ9&DfADPvKBOmknJzsnZv(h)OU%Oxx4HW>h7j4p*6~NMpWM=>P8<>Hx_6080;wQxB@K6__-d3 z&C=9WGG>6n2@gR~Y(gvpJ8jqy>9QNW`55tfjW%Zk6evZHyVYa;id&6O`!d|uP z=I!e>V%B`uH|X@r{!y+hzL%?3#T^;@^80xsJW+0=Lxg z$^?oqxBFrHf%~xgWC6Q`osRw3Id1VSo)hLF zj>Wg`v-H-%UkBgVh1qW8(O$b+r|dEOhcUbnDOGD)rB>3u5a0i@RLfOph+z!fN%{lB z7-ASh3}c94IPo9eTI4$Mt8^k^h13N!sfi{xC9IH4j*u+3bsT;!p-cJI>>S_5*(>2b z=mfC>s?-cXKZu24pV)YI4iA`oAMOQD=OYgBhK^5Vb2!&AdVR^5KTiQ!+5YB5KbW=+mKs$^?G>q^gx-PBXC66%w`+FHdqTjJrj8q{#DE0NM5fI zvrf~$_}yOL&wW+Xv|3U8ppa8(j9+c+&e31!_Za;iqu*on+ll|Qr&BI*Kl46XpwObW zm*|-%je`e=^-M!k)W$BAh8z`T+tKf30~~7 z>;IM4|6%2@Sg6)=MLMBEa0wI^k* zT2RIO*J50&lxd2}rK02%ANQxfG&2O?d0jqixtQ(ph=w_q7H`N2@t0X z{5;zcdbXLDI-4`Ur29Gk99NIu1RjJ&kOUrss|Cax81zkCIJ*zb(9rdGZ0Ek?UY@pO z=ECEl(~+Rbd0dzKw~*HsY@Y8$`AFbd_nSFE$IfdD{vcLJ&zbi?=!Brjb@@G;!1dBRc|Z2_J0#wR&YpgMdu_pZ7r5}f{SVF2 B=s5rY literal 0 HcmV?d00001 diff --git a/music/mus/mus.go b/music/mus/mus.go new file mode 100644 index 0000000..09a7155 --- /dev/null +++ b/music/mus/mus.go @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom mus2mid.c (Ben Ryves 2006, Simon Howard, +// id Software). Converts a DMX MUS lump (the D_* music lumps) into a +// single-track type-0 Standard MIDI File, byte-for-byte identical to +// chocolate-doom's mus2mid(). Ported for the go-doom/engine authors. +// +// Pure Go, CGO=0. The output of Convert is intended to be handed to the +// MIDI-driven OPL player, exactly as chocolate-doom does. + +// Package mus parses the DMX MUS music format used by DOOM's D_* lumps and +// converts it to a Standard MIDI File, matching chocolate-doom's mus2mid +// conversion byte-for-byte. +package mus + +import ( + "encoding/binary" + "errors" +) + +// MUS event codes (high nibble group, bits 4-6 of the event descriptor). +const ( + musReleaseKey = 0x00 + musPressKey = 0x10 + musPitchWheel = 0x20 + musSystemEvent = 0x30 + musChangeController = 0x40 + musScoreEnd = 0x60 +) + +// MIDI channel voice message status bytes. +const ( + midiReleaseKey = 0x80 + midiPressKey = 0x90 + midiChangeController = 0xB0 + midiChangePatch = 0xC0 + midiPitchWheel = 0xE0 +) + +const ( + numChannels = 16 + midiPercussionChan = 9 + musPercussionChan = 15 +) + +// controllerMap maps MUS controller numbers to MIDI controller numbers. +var controllerMap = [15]byte{ + 0x00, 0x20, 0x01, 0x07, 0x0A, 0x0B, 0x5B, 0x5D, + 0x40, 0x43, 0x78, 0x7B, 0x7E, 0x7F, 0x79, +} + +// Standard MIDI type-0 header + track header. The last 4 bytes are a +// placeholder for the track length, patched in after conversion. +var midiHeader = []byte{ + 'M', 'T', 'h', 'd', + 0x00, 0x00, 0x00, 0x06, + 0x00, 0x00, + 0x00, 0x01, + 0x00, 0x46, + 'M', 'T', 'r', 'k', + 0x00, 0x00, 0x00, 0x00, +} + +// Errors returned by Convert. +var ( + ErrShortHeader = errors.New("mus: truncated MUS header") + ErrConvert = errors.New("mus: malformed MUS score") +) + +// Header is the parsed MUS lump header. +type Header struct { + ID [4]byte + ScoreLength uint16 + ScoreStart uint16 + PrimaryChannels uint16 + SecondaryChannels uint16 + InstrumentCount uint16 +} + +// IsMUS reports whether b begins with the DMX MUS magic ("MUS\x1a"). +func IsMUS(b []byte) bool { + return len(b) >= 4 && b[0] == 'M' && b[1] == 'U' && b[2] == 'S' && b[3] == 0x1A +} + +// ParseHeader reads the 14-byte MUS header from b. +func ParseHeader(b []byte) (Header, error) { + var h Header + if len(b) < 14 { + return h, ErrShortHeader + } + copy(h.ID[:], b[0:4]) + h.ScoreLength = binary.LittleEndian.Uint16(b[4:6]) + h.ScoreStart = binary.LittleEndian.Uint16(b[6:8]) + h.PrimaryChannels = binary.LittleEndian.Uint16(b[8:10]) + h.SecondaryChannels = binary.LittleEndian.Uint16(b[10:12]) + h.InstrumentCount = binary.LittleEndian.Uint16(b[12:14]) + return h, nil +} + +// converter holds the per-conversion mutable state, mirroring the static +// globals in mus2mid.c but scoped to a single Convert call. +type converter struct { + out []byte + tracksize uint32 + queuedtime uint32 + channelVelocities [numChannels]byte + channelMap [numChannels]int + mus []byte + pos int +} + +func (c *converter) readByte() (byte, bool) { + if c.pos >= len(c.mus) { + return 0, false + } + b := c.mus[c.pos] + c.pos++ + return b, true +} + +func (c *converter) writeByte(b byte) { + c.out = append(c.out, b) +} + +// writeTime emits a MIDI variable-length delta time and resets queuedtime. +func (c *converter) writeTime(t uint32) { + buffer := t & 0x7F + for t >>= 7; t != 0; t >>= 7 { + buffer <<= 8 + buffer |= (t & 0x7F) | 0x80 + } + for { + c.writeByte(byte(buffer & 0xFF)) + c.tracksize++ + if buffer&0x80 != 0 { + buffer >>= 8 + } else { + c.queuedtime = 0 + return + } + } +} + +func (c *converter) writeEndTrack() { + c.writeTime(c.queuedtime) + c.out = append(c.out, 0xFF, 0x2F, 0x00) + c.tracksize += 3 +} + +func (c *converter) writePressKey(channel, key, velocity byte) { + c.writeTime(c.queuedtime) + c.writeByte(midiPressKey | channel) + c.writeByte(key & 0x7F) + c.writeByte(velocity & 0x7F) + c.tracksize += 3 +} + +func (c *converter) writeReleaseKey(channel, key byte) { + c.writeTime(c.queuedtime) + c.writeByte(midiReleaseKey | channel) + c.writeByte(key & 0x7F) + c.writeByte(0) + c.tracksize += 3 +} + +func (c *converter) writePitchWheel(channel byte, wheel int16) { + c.writeTime(c.queuedtime) + c.writeByte(midiPitchWheel | channel) + c.writeByte(byte(wheel) & 0x7F) + c.writeByte(byte(wheel>>7) & 0x7F) + c.tracksize += 3 +} + +func (c *converter) writeChangePatch(channel, patch byte) { + c.writeTime(c.queuedtime) + c.writeByte(midiChangePatch | channel) + c.writeByte(patch & 0x7F) + c.tracksize += 2 +} + +func (c *converter) writeChangeControllerValued(channel, control, value byte) { + c.writeTime(c.queuedtime) + c.writeByte(midiChangeController | channel) + c.writeByte(control & 0x7F) + // Quirk in vanilla DOOM: MUS controller values should be 7-bit. Clamp + // an out-of-range 8-bit value to 0x7F so MIDI players do not complain. + working := value + if working&0x80 != 0 { + working = 0x7F + } + c.writeByte(working) + c.tracksize += 3 +} + +func (c *converter) writeChangeControllerValueless(channel, control byte) { + c.writeChangeControllerValued(channel, control, 0) +} + +// allocateMIDIChannel returns the next free MIDI channel, skipping the +// percussion channel (9). +func (c *converter) allocateMIDIChannel() int { + max := -1 + for i := 0; i < numChannels; i++ { + if c.channelMap[i] > max { + max = c.channelMap[i] + } + } + result := max + 1 + if result == midiPercussionChan { + result++ + } + return result +} + +// getMIDIChannel maps a MUS channel to a MIDI channel, allocating on first +// use and emitting an all-notes-off on that first use (the D_DDTBLU fix). +func (c *converter) getMIDIChannel(musChannel int) int { + if musChannel == musPercussionChan { + return midiPercussionChan + } + if c.channelMap[musChannel] == -1 { + c.channelMap[musChannel] = c.allocateMIDIChannel() + c.writeChangeControllerValueless(byte(c.channelMap[musChannel]), 0x7B) + } + return c.channelMap[musChannel] +} + +// Convert converts a MUS lump to a type-0 MIDI file, byte-for-byte identical +// to chocolate-doom's mus2mid(). It does not check the MUS magic (matching +// chocolate-doom's default build), only the header length. +func Convert(mus []byte) ([]byte, error) { + hdr, err := ParseHeader(mus) + if err != nil { + return nil, err + } + + c := &converter{ + out: make([]byte, 0, len(mus)*2+len(midiHeader)), + mus: mus, + pos: int(hdr.ScoreStart), + } + for i := range c.channelVelocities { + c.channelVelocities[i] = 127 + } + for i := range c.channelMap { + c.channelMap[i] = -1 + } + if c.pos > len(mus) { + return nil, ErrConvert + } + + c.out = append(c.out, midiHeader...) + c.tracksize = 0 + + hitScoreEnd := false + for !hitScoreEnd { + for !hitScoreEnd { + eventDescriptor, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + channel := c.getMIDIChannel(int(eventDescriptor & 0x0F)) + event := int(eventDescriptor & 0x70) + + switch event { + case musReleaseKey: + key, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + c.writeReleaseKey(byte(channel), key) + + case musPressKey: + key, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + if key&0x80 != 0 { + vel, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + c.channelVelocities[channel] = vel & 0x7F + } + c.writePressKey(byte(channel), key, c.channelVelocities[channel]) + + case musPitchWheel: + key, ok := c.readByte() + if !ok { + // Matches mus2mid.c: a truncated pitch event breaks + // the inner loop rather than erroring. + break + } + c.writePitchWheel(byte(channel), int16(uint16(key)*64)) + + case musSystemEvent: + controllerNumber, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + if controllerNumber < 10 || controllerNumber > 14 { + return nil, ErrConvert + } + c.writeChangeControllerValueless(byte(channel), controllerMap[controllerNumber]) + + case musChangeController: + controllerNumber, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + controllerValue, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + if controllerNumber == 0 { + c.writeChangePatch(byte(channel), controllerValue) + } else { + if controllerNumber < 1 || controllerNumber > 9 { + return nil, ErrConvert + } + c.writeChangeControllerValued(byte(channel), + controllerMap[controllerNumber], controllerValue) + } + + case musScoreEnd: + hitScoreEnd = true + + default: + return nil, ErrConvert + } + + if eventDescriptor&0x80 != 0 { + break + } + } + + if !hitScoreEnd { + var timedelay uint32 + for { + working, ok := c.readByte() + if !ok { + return nil, ErrConvert + } + timedelay = timedelay*128 + uint32(working&0x7F) + if working&0x80 == 0 { + break + } + } + c.queuedtime += timedelay + } + } + + c.writeEndTrack() + + // Patch the track size big-endian at offset 18. + binary.BigEndian.PutUint32(c.out[18:22], c.tracksize) + return c.out, nil +} diff --git a/music/mus/mus_test.go b/music/mus/mus_test.go new file mode 100644 index 0000000..e9805d5 --- /dev/null +++ b/music/mus/mus_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. + +package mus + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +// The .mid golden files under testdata were produced by compiling +// chocolate-doom's mus2mid.c (unmodified) into a standalone oracle and +// running it on the matching .mus vector. This test asserts our pure-Go +// Convert is BYTE-IDENTICAL to that reference implementation. +func TestConvertByteExactVsChocolateDoom(t *testing.T) { + vectors := []string{"basic", "allevents", "ctrlhigh", "bigdelay"} + for _, name := range vectors { + t.Run(name, func(t *testing.T) { + musData, err := os.ReadFile(filepath.Join("testdata", name+".mus")) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join("testdata", name+".mid")) + if err != nil { + t.Fatal(err) + } + got, err := Convert(musData) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("not byte-exact vs mus2mid oracle:\n got %d bytes: %x\nwant %d bytes: %x", + len(got), got, len(want), want) + } + }) + } +} + +func TestIsMUS(t *testing.T) { + if !IsMUS([]byte("MUS\x1a....")) { + t.Error("valid MUS magic rejected") + } + if IsMUS([]byte("MThd")) { + t.Error("MIDI accepted as MUS") + } + if IsMUS([]byte("MU")) { + t.Error("short buffer accepted") + } +} + +func TestParseHeaderShort(t *testing.T) { + if _, err := ParseHeader([]byte("MUS\x1a")); !errors.Is(err, ErrShortHeader) { + t.Errorf("want ErrShortHeader, got %v", err) + } +} + +func TestParseHeaderFields(t *testing.T) { + musData, err := os.ReadFile(filepath.Join("testdata", "basic.mus")) + if err != nil { + t.Fatal(err) + } + h, err := ParseHeader(musData) + if err != nil { + t.Fatal(err) + } + if h.ID != [4]byte{'M', 'U', 'S', 0x1A} { + t.Errorf("bad id %v", h.ID) + } + if h.ScoreStart != 14 { + t.Errorf("ScoreStart=%d want 14", h.ScoreStart) + } + if h.PrimaryChannels != 1 { + t.Errorf("PrimaryChannels=%d want 1", h.PrimaryChannels) + } +} + +// mkMUS builds a MUS lump with score body starting at offset 14. +func mkMUS(score ...byte) []byte { + b := []byte{'M', 'U', 'S', 0x1A, + 0, 0, // scorelength (ignored) + 14, 0, // scorestart = 14 + 1, 0, // primary channels + 0, 0, // secondary channels + 0, 0, // instrument count + } + return append(b, score...) +} + +func TestConvertErrorPaths(t *testing.T) { + cases := []struct { + name string + score []byte + }{ + // press-key missing key byte + {"truncated-presskey", []byte{musPressKey | 0}}, + // press-key with velocity flag but missing velocity byte + {"truncated-velocity", []byte{musPressKey | 0, 0x80 | 60}}, + // release-key missing key + {"truncated-release", []byte{musReleaseKey | 0}}, + // system event missing controller number + {"truncated-system", []byte{musSystemEvent | 0}}, + // system event with out-of-range controller number (<10) + {"bad-system-low", []byte{musSystemEvent | 0, 5}}, + // system event with out-of-range controller number (>14) + {"bad-system-high", []byte{musSystemEvent | 0, 20}}, + // change controller missing number + {"truncated-ctrl-num", []byte{musChangeController | 0}}, + // change controller missing value + {"truncated-ctrl-val", []byte{musChangeController | 0, 3}}, + // change controller with out-of-range number + {"bad-ctrl-num", []byte{musChangeController | 0, 200, 0}}, + // reserved event type 0x50 -> default -> error + {"reserved-event", []byte{0x50}}, + // no score-end, truncated time bytes + {"truncated-time", []byte{musPressKey | 0, 60}}, + // truncated at first event read (empty score, no scoreend) + {"empty-score", []byte{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Convert(mkMUS(tc.score...)) + if !errors.Is(err, ErrConvert) { + t.Errorf("want ErrConvert, got %v", err) + } + }) + } +} + +// TestPitchWheelTruncatedBreak covers the mus2mid.c quirk where a +// truncated pitch-wheel event (descriptor with the group-end bit set but no +// data byte) breaks the event loop; the missing following time byte then +// surfaces as ErrConvert. +func TestPitchWheelTruncatedBreak(t *testing.T) { + // pitch-wheel, group-end bit set (0x80), no data byte, nothing after. + if _, err := Convert(mkMUS(0x80 | musPitchWheel | 0)); !errors.Is(err, ErrConvert) { + t.Errorf("want ErrConvert, got %v", err) + } +} + +func TestConvertShortHeader(t *testing.T) { + if _, err := Convert([]byte("MUS")); !errors.Is(err, ErrShortHeader) { + t.Errorf("want ErrShortHeader, got %v", err) + } +} + +func TestConvertScoreStartBeyondEnd(t *testing.T) { + // scorestart points past the end of the lump. + b := []byte{'M', 'U', 'S', 0x1A, 0, 0, 100, 0, 1, 0, 0, 0, 0, 0} + if _, err := Convert(b); !errors.Is(err, ErrConvert) { + t.Errorf("want ErrConvert, got %v", err) + } +} + +// TestPitchWheelTruncatedBreaks verifies the mus2mid.c quirk where a +// truncated pitch-wheel event breaks the event loop instead of erroring, +// and the score still terminates cleanly via a following score-end. Here we +// place a lone pitch event whose data byte IS present but that is the last +// event of a group (0x80) followed by a valid time and a score-end. +func TestPitchWheelAndReuseVelocity(t *testing.T) { + // press with vel, press reusing vel, pitch wheel, then score end. + score := []byte{ + musPressKey | 0, 0x80 | 60, 90, + musPressKey | 0, 64, // reuse cached velocity + musPitchWheel | 0, 100, + musScoreEnd | 0, + } + out, err := Convert(mkMUS(score...)) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !bytes.HasPrefix(out, midiHeader[:14]) { + t.Error("output missing MIDI header") + } +} diff --git a/music/mus/testdata/allevents.mid b/music/mus/testdata/allevents.mid new file mode 100644 index 0000000000000000000000000000000000000000..0aa8105ec51a039cb404739107bbdf0607a68355 GIT binary patch literal 203 zcmW;F$qGRM9EI_7Q&)ykN+}y+n(2Kln~IK%^v@Q7nP;RMe(#S6~xigUc-0`IuO2d?pn8+_pw w-?+nNrq$1WRCBDQ|G9OsM{P<6WR5EfhGgNWEE1Dd;<9Q&R!GWKkK~NA85flMC@bF6520RfHvnR{oY$KS^!B$~egqi1SXx}b(-mMDp zB$nb?ti)Qph*z-@Z(=KUVlUpsX&%3i-``tmd&}%#Ieds9MwnoeAvz*1v8}OdkaV8~ Dv$`3p literal 0 HcmV?d00001 diff --git a/music/mus/testdata/basic.mid b/music/mus/testdata/basic.mid new file mode 100644 index 0000000000000000000000000000000000000000..a43c83dc0e73545798bc9b087ffb5fb6644425fc GIT binary patch literal 49 zcmeYb$w*;fU|<7cMg}+EkfLlLOPXOrH3P!|4TcFeDGU=FQrsGB7?c|v7#RNRGXMba CI||+a literal 0 HcmV?d00001 diff --git a/music/mus/testdata/basic.mus b/music/mus/testdata/basic.mus new file mode 100644 index 0000000000000000000000000000000000000000..81cbd3dfd8244cd154ab46f4218727a9fca92bad GIT binary patch literal 29 hcmeYb4VL0(;A3E9009RE4S_u=6CB(cY?K)s5&%JK1zP|B literal 0 HcmV?d00001 diff --git a/music/mus/testdata/bigdelay.mid b/music/mus/testdata/bigdelay.mid new file mode 100644 index 0000000000000000000000000000000000000000..5b2b774952bb87ff9796edd604e76541fe2e9d0c GIT binary patch literal 40 scmeYb$w*;fU|<7cMg}+EkfLlLONe1ZH3P#0jewR#1q~Vu4FB~R0Gjg%>Hq)$ literal 0 HcmV?d00001 diff --git a/music/mus/testdata/bigdelay.mus b/music/mus/testdata/bigdelay.mus new file mode 100644 index 0000000000000000000000000000000000000000..78eb0748dcd29e9b093eb9b6cbc9981b29c9c66a GIT binary patch literal 23 ccmeYb4VL0$;A3E90D%cB0$LUoFlZzI04@Op&Hw-a literal 0 HcmV?d00001 diff --git a/music/mus/testdata/ctrlhigh.mid b/music/mus/testdata/ctrlhigh.mid new file mode 100644 index 0000000000000000000000000000000000000000..0f985f11d6dcb6df38fdd09537d53a7d4ee61e03 GIT binary patch literal 38 qcmeYb$w*;fU|<7cMg}+EkfLlLOMqcRH3P#2?s|p^ZuJcR^%($${|GSv literal 0 HcmV?d00001 diff --git a/music/mus/testdata/ctrlhigh.mus b/music/mus/testdata/ctrlhigh.mus new file mode 100644 index 0000000000000000000000000000000000000000..3e38d84ab34007fc1e892d16a5bf9c5cb8423d5e GIT binary patch literal 21 ZcmeYb4VGeO;A3E9009Tq0~6dB5&$1<17QFF literal 0 HcmV?d00001 diff --git a/music/opl/opl3.go b/music/opl/opl3.go new file mode 100644 index 0000000..085f06d --- /dev/null +++ b/music/opl/opl3.go @@ -0,0 +1,1303 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Go port of Nuked OPL3 (github.com/nukeykt/Nuked-OPL3) by Nuke.YKT. +// Ported for the go-doom/engine authors. Original C: LGPL-2.1-or-later. +// +// This is a faithful, bit-exact port of the Nuked-OPL3-fast fork +// (https://github.com/tgies/Nuked-OPL3-fast, upstream commit cfedb09, +// fork 1.8-fast.1) of the Yamaha YMF262 (OPL3) FM synthesis chip as +// shipped in chocolate-doom's OPL music backend. Audio output is +// identical to the C reference for the same register stream. +// +// The port keeps the same integer widths (uint8/int16/uint16/uint32/ +// int32/uint64) and the same wraparound arithmetic as the C source, and +// reproduces the pointer aliasing between operator slots and channels +// using Go pointers into the (never-moved) Chip struct. +// +// The stereo-extension and 4-channel (OPL_ENABLE_STEREOEXT) paths of the +// upstream source are compiled out in the chocolate-doom configuration +// (OPL_ENABLE_STEREOEXT == 0, OPL_QUIRK_CHANNELSAMPLEDELAY == 1); this +// port implements that exact configuration. + +package opl + +import "math/bits" + +// Compile-time configuration matching chocolate-doom's opl3.c: +// OPL_ENABLE_STEREOEXT == 0 +// OPL_QUIRK_CHANNELSAMPLEDELAY == 1 + +const ( + writebufSize = 1024 + writebufDelay = 2 + rsmFrac = 10 +) + +// Channel types. +const ( + ch2op = 0 + ch4op = 1 + ch4op2 = 2 + chDrum = 3 +) + +// Envelope key types. +const ( + egkNorm = 0x01 + egkDrum = 0x02 +) + +// Envelope generator phases. +const ( + envAttack = 0 + envDecay = 1 + envSustain = 2 + envRelease = 3 +) + +// exp table (verbatim from opl3.c). +var exprom = [256]uint16{ + 0xff4, 0xfea, 0xfde, 0xfd4, 0xfc8, 0xfbe, 0xfb4, 0xfa8, + 0xf9e, 0xf92, 0xf88, 0xf7e, 0xf72, 0xf68, 0xf5c, 0xf52, + 0xf48, 0xf3e, 0xf32, 0xf28, 0xf1e, 0xf14, 0xf08, 0xefe, + 0xef4, 0xeea, 0xee0, 0xed4, 0xeca, 0xec0, 0xeb6, 0xeac, + 0xea2, 0xe98, 0xe8e, 0xe84, 0xe7a, 0xe70, 0xe66, 0xe5c, + 0xe52, 0xe48, 0xe3e, 0xe34, 0xe2a, 0xe20, 0xe16, 0xe0c, + 0xe04, 0xdfa, 0xdf0, 0xde6, 0xddc, 0xdd2, 0xdca, 0xdc0, + 0xdb6, 0xdac, 0xda4, 0xd9a, 0xd90, 0xd88, 0xd7e, 0xd74, + 0xd6a, 0xd62, 0xd58, 0xd50, 0xd46, 0xd3c, 0xd34, 0xd2a, + 0xd22, 0xd18, 0xd10, 0xd06, 0xcfe, 0xcf4, 0xcec, 0xce2, + 0xcda, 0xcd0, 0xcc8, 0xcbe, 0xcb6, 0xcae, 0xca4, 0xc9c, + 0xc92, 0xc8a, 0xc82, 0xc78, 0xc70, 0xc68, 0xc60, 0xc56, + 0xc4e, 0xc46, 0xc3c, 0xc34, 0xc2c, 0xc24, 0xc1c, 0xc12, + 0xc0a, 0xc02, 0xbfa, 0xbf2, 0xbea, 0xbe0, 0xbd8, 0xbd0, + 0xbc8, 0xbc0, 0xbb8, 0xbb0, 0xba8, 0xba0, 0xb98, 0xb90, + 0xb88, 0xb80, 0xb78, 0xb70, 0xb68, 0xb60, 0xb58, 0xb50, + 0xb48, 0xb40, 0xb38, 0xb32, 0xb2a, 0xb22, 0xb1a, 0xb12, + 0xb0a, 0xb02, 0xafc, 0xaf4, 0xaec, 0xae4, 0xade, 0xad6, + 0xace, 0xac6, 0xac0, 0xab8, 0xab0, 0xaa8, 0xaa2, 0xa9a, + 0xa92, 0xa8c, 0xa84, 0xa7c, 0xa76, 0xa6e, 0xa68, 0xa60, + 0xa58, 0xa52, 0xa4a, 0xa44, 0xa3c, 0xa36, 0xa2e, 0xa28, + 0xa20, 0xa18, 0xa12, 0xa0c, 0xa04, 0x9fe, 0x9f6, 0x9f0, + 0x9e8, 0x9e2, 0x9da, 0x9d4, 0x9ce, 0x9c6, 0x9c0, 0x9b8, + 0x9b2, 0x9ac, 0x9a4, 0x99e, 0x998, 0x990, 0x98a, 0x984, + 0x97c, 0x976, 0x970, 0x96a, 0x962, 0x95c, 0x956, 0x950, + 0x948, 0x942, 0x93c, 0x936, 0x930, 0x928, 0x922, 0x91c, + 0x916, 0x910, 0x90a, 0x904, 0x8fc, 0x8f6, 0x8f0, 0x8ea, + 0x8e4, 0x8de, 0x8d8, 0x8d2, 0x8cc, 0x8c6, 0x8c0, 0x8ba, + 0x8b4, 0x8ae, 0x8a8, 0x8a2, 0x89c, 0x896, 0x890, 0x88a, + 0x884, 0x87e, 0x878, 0x872, 0x86c, 0x866, 0x860, 0x85a, + 0x854, 0x850, 0x84a, 0x844, 0x83e, 0x838, 0x832, 0x82c, + 0x828, 0x822, 0x81c, 0x816, 0x810, 0x80c, 0x806, 0x800, +} + +// freq mult table multiplied by 2. +var mt = [16]uint8{ + 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 20, 24, 24, 30, 30, +} + +// ksl table. +var kslrom = [16]uint8{ + 0, 32, 40, 45, 48, 51, 53, 55, 56, 58, 59, 60, 61, 62, 63, 64, +} + +var kslshift = [4]uint8{ + 8, 1, 2, 0, +} + +// envelope generator constants. +var egIncstep = [4][4]uint8{ + {0, 0, 0, 0}, + {1, 0, 0, 0}, + {1, 0, 1, 0}, + {1, 1, 1, 0}, +} + +// address decoding. +var adSlot = [0x20]int8{ + 0, 1, 2, 3, 4, 5, -1, -1, 6, 7, 8, 9, 10, 11, -1, -1, + 12, 13, 14, 15, 16, 17, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, +} + +var chSlot = [18]uint8{ + 0, 1, 2, 6, 7, 8, 12, 13, 14, 18, 19, 20, 24, 25, 26, 30, 31, 32, +} + +// slot is the per-operator state (opl3_slot). +type slot struct { + channel *channel + chip *Chip + mod *int16 + trem *uint8 + pgReset uint32 + pgPhase uint32 + pgInc uint32 + out int16 + fbmod int16 + prout int16 + egRout uint16 + egOut uint16 + // egTlKsl caches (regTl << 2) + (egKsl >> kslshift[regKsl]); maintained + // by envelopeUpdateKSL whenever any of those inputs change. + egTlKsl uint16 + pgPhaseOut uint16 + key uint8 + egGen uint8 + regVib uint8 + regMult uint8 + regWf uint8 + slotNum uint8 + egKsl uint8 + egKs uint8 + regType uint8 + regKsr uint8 + regKsl uint8 + regTl uint8 + regAr uint8 + regDr uint8 + regSl uint8 + regRr uint8 + egRates [4]uint8 + egRateHi [4]uint8 + egRateLo [4]uint8 +} + +// channel is the per-channel state (opl3_channel). +type channel struct { + slotz [2]*slot + pair *channel + chip *Chip + out [4]*int16 + outCnt uint8 + chtype uint8 + fNum uint16 + block uint8 + fb uint8 + con uint8 + alg uint8 + ksv uint8 + cha uint16 + chb uint16 + chc uint16 + chd uint16 + chNum uint8 +} + +type writebuf struct { + time uint64 + reg uint16 + data uint8 +} + +// Chip is the full OPL3 emulator state (opl3_chip / opl3_chip). +type Chip struct { + channel [18]channel + slot [36]slot + timer uint16 + egTimer uint64 + + egTimerrem uint8 + egState uint8 + egAdd uint8 + egTimerLo uint8 + newm uint8 + nts uint8 + rhy uint8 + vibpos uint8 + vibshift uint8 + tremolo uint8 + tremolopos uint8 + tremoloshift uint8 + tremoloDirty uint8 + + noise uint32 + zeromod int16 + // zeroTrem is always 0 and stands in for the C cast + // (uint8_t*)&chip->zeromod: since zeromod is never written to a + // non-zero value, its low byte read through a uint8_t* is always 0. + zeroTrem uint8 + mixbuff [4]int32 + + rmHhBit2 uint8 + rmHhBit3 uint8 + rmHhBit7 uint8 + rmHhBit8 uint8 + rmTcBit3 uint8 + rmTcBit5 uint8 + + // OPL3L resampler. + rateratio int32 + samplecnt int32 + oldsamples [4]int16 + samples [4]int16 + + writebufSamplecnt uint64 + writebufCur uint32 + writebufLast uint32 + writebufLasttime uint64 + writebuf [writebufSize]writebuf +} + +// +// Envelope generator +// + +func opl3EnvelopeUpdateKSL(s *slot) { + ksl := int32(kslrom[s.channel.fNum>>6])<<2 - int32(0x08-s.channel.block)<<5 + if ksl < 0 { + ksl = 0 + } + s.egKsl = uint8(ksl) + s.egTlKsl = uint16(s.regTl)<<2 + (uint16(s.egKsl) >> kslshift[s.regKsl]) +} + +func opl3EnvelopeUpdateRate(s *slot) { + s.egKs = s.channel.ksv >> ((s.regKsr ^ 1) << 1) + for ii := 0; ii < 4; ii++ { + rate := s.egKs + (s.egRates[ii] << 2) + rateHi := rate >> 2 + if rateHi&0x10 != 0 { + rateHi = 0x0f + } + s.egRateHi[ii] = rateHi + s.egRateLo[ii] = rate & 0x03 + } +} + +func opl3EnvelopeCalc(s *slot) { + var regRate uint8 + reset := uint8(0) + + s.egOut = s.egRout + s.egTlKsl + uint16(*s.trem) + if s.key != 0 && s.egGen == envRelease { + reset = 1 + regRate = s.egRates[0] + } else { + regRate = s.egRates[s.egGen] + } + s.pgReset = uint32(reset) + nonzero := regRate != 0 + + var rateHi, rateLo uint8 + if reset != 0 { + rateHi = s.egRateHi[0] + rateLo = s.egRateLo[0] + } else { + rateHi = s.egRateHi[s.egGen] + rateLo = s.egRateLo[s.egGen] + } + egShift := rateHi + s.chip.egAdd + shift := uint8(0) + if nonzero { + if rateHi < 12 { + if s.chip.egState != 0 { + switch egShift { + case 12: + shift = 1 + case 13: + shift = (rateLo >> 1) & 0x01 + case 14: + shift = rateLo & 0x01 + } + } + } else { + shift = (rateHi & 0x03) + egIncstep[rateLo][s.chip.egTimerLo] + if shift&0x04 != 0 { + shift = 0x03 + } + if shift == 0 { + shift = s.chip.egState + } + } + } + egRout := s.egRout + egInc := int16(0) + egOff := uint8(0) + // Instant attack. + if reset != 0 && rateHi == 0x0f { + egRout = 0x00 + } + // Envelope off. + if (s.egRout & 0x1f8) == 0x1f8 { + egOff = 1 + } + if s.egGen != envAttack && reset == 0 && egOff != 0 { + egRout = 0x1ff + } + switch s.egGen { + case envAttack: + if s.egRout == 0 { + s.egGen = envDecay + } else if s.key != 0 && shift > 0 && rateHi != 0x0f { + egInc = int16(^int32(s.egRout) >> (4 - shift)) + } + case envDecay: + if (s.egRout >> 4) == uint16(s.regSl) { + s.egGen = envSustain + } else if egOff == 0 && reset == 0 && shift > 0 { + egInc = int16(1) << (shift - 1) + } + case envSustain, envRelease: + if egOff == 0 && reset == 0 && shift > 0 { + egInc = int16(1) << (shift - 1) + } + } + s.egRout = uint16((int32(egRout) + int32(egInc)) & 0x1ff) + // Key off. + if reset != 0 { + s.egGen = envAttack + } + if s.key == 0 { + s.egGen = envRelease + } +} + +func opl3EnvelopeKeyOn(s *slot, typ uint8) { + s.key |= typ +} + +func opl3EnvelopeKeyOff(s *slot, typ uint8) { + s.key &^= typ +} + +// +// Phase Generator +// + +func opl3PhaseUpdateInc(s *slot) { + basefreq := (uint32(s.channel.fNum) << s.channel.block) >> 1 + s.pgInc = (basefreq * uint32(mt[s.regMult])) >> 1 +} + +func opl3PhaseGenerate(s *slot) { + chip := s.chip + var phaseinc uint32 + if s.regVib != 0 { + fNum := s.channel.fNum + rng := int8((fNum >> 7) & 7) + vibpos := chip.vibpos + + if vibpos&3 == 0 { + rng = 0 + } else if vibpos&1 != 0 { + rng >>= 1 + } + rng >>= chip.vibshift + + if vibpos&4 != 0 { + rng = -rng + } + fNum += uint16(rng) + basefreq := (uint32(fNum) << s.channel.block) >> 1 + phaseinc = (basefreq * uint32(mt[s.regMult])) >> 1 + } else { + phaseinc = s.pgInc + } + phase := uint16(s.pgPhase >> 9) + if s.pgReset != 0 { + s.pgPhase = 0 + } + s.pgPhase += phaseinc + noise := chip.noise + s.pgPhaseOut = phase + switch s.slotNum { + case 13: // hh + chip.rmHhBit2 = uint8(phase>>2) & 1 + chip.rmHhBit3 = uint8(phase>>3) & 1 + chip.rmHhBit7 = uint8(phase>>7) & 1 + chip.rmHhBit8 = uint8(phase>>8) & 1 + if chip.rhy&0x20 != 0 { + rmXor := (chip.rmHhBit2 ^ chip.rmHhBit7) | + (chip.rmHhBit3 ^ chip.rmTcBit5) | + (chip.rmTcBit3 ^ chip.rmTcBit5) + s.pgPhaseOut = uint16(rmXor) << 9 + if rmXor^uint8(noise&1) != 0 { + s.pgPhaseOut |= 0xd0 + } else { + s.pgPhaseOut |= 0x34 + } + } + case 16: // sd + if chip.rhy&0x20 != 0 { + s.pgPhaseOut = (uint16(chip.rmHhBit8) << 9) | + (uint16(chip.rmHhBit8^uint8(noise&1)) << 8) + } + case 17: // tc + if chip.rhy&0x20 != 0 { + chip.rmTcBit3 = uint8(phase>>3) & 1 + chip.rmTcBit5 = uint8(phase>>5) & 1 + rmXor := (chip.rmHhBit2 ^ chip.rmHhBit7) | + (chip.rmHhBit3 ^ chip.rmTcBit5) | + (chip.rmTcBit3 ^ chip.rmTcBit5) + s.pgPhaseOut = (uint16(rmXor) << 9) | 0x80 + } + } + nBit := uint8((noise>>14)^noise) & 0x01 + chip.noise = (noise >> 1) | (uint32(nBit) << 22) +} + +// +// Slot +// + +func opl3SlotWrite20(s *slot, data uint8) { + if (data>>7)&0x01 != 0 { + s.trem = &s.chip.tremolo + } else { + s.trem = &s.chip.zeroTrem + } + s.regVib = (data >> 6) & 0x01 + s.regType = (data >> 5) & 0x01 + if s.regType != 0 { + s.egRates[2] = 0 + } else { + s.egRates[2] = s.regRr + } + s.regKsr = (data >> 4) & 0x01 + s.regMult = data & 0x0f + opl3EnvelopeUpdateRate(s) + opl3PhaseUpdateInc(s) +} + +func opl3SlotWrite40(s *slot, data uint8) { + s.regKsl = (data >> 6) & 0x03 + s.regTl = data & 0x3f + opl3EnvelopeUpdateKSL(s) +} + +func opl3SlotWrite60(s *slot, data uint8) { + s.regAr = (data >> 4) & 0x0f + s.regDr = data & 0x0f + s.egRates[0] = s.regAr + s.egRates[1] = s.regDr + opl3EnvelopeUpdateRate(s) +} + +func opl3SlotWrite80(s *slot, data uint8) { + s.regSl = (data >> 4) & 0x0f + if s.regSl == 0x0f { + s.regSl = 0x1f + } + s.regRr = data & 0x0f + if s.regType != 0 { + s.egRates[2] = 0 + } else { + s.egRates[2] = s.regRr + } + s.egRates[3] = s.regRr + opl3EnvelopeUpdateRate(s) +} + +func opl3SlotWriteE0(s *slot, data uint8) { + s.regWf = data & 0x07 + if s.chip.newm == 0x00 { + s.regWf &= 0x03 + } +} + +func opl3SlotGenerate(s *slot) { + phase := s.pgPhaseOut + uint16(*s.mod) + envelope := s.egOut + wfData := logsinWF[s.regWf][phase&0x3ff] + neg := uint16(int16(wfData) >> 15) + level := uint32(wfData&0x7fff) + (uint32(envelope) << 3) + if level > 0x1fff { + level = 0x1fff + } + s.out = int16((exprom[level&0xff] >> (level >> 8)) ^ neg) +} + +func opl3SlotGenerateSilent(s *slot) { + phase := s.pgPhaseOut + uint16(*s.mod) + wfData := logsinWF[s.regWf][phase&0x3ff] + s.out = int16(wfData) >> 15 +} + +func opl3SlotCalcFB(s *slot) { + if s.channel.fb != 0x00 { + s.fbmod = int16((int32(s.prout) + int32(s.out)) >> (0x09 - s.channel.fb)) + } else { + s.fbmod = 0 + } + s.prout = s.out +} + +// +// Channel +// + +func opl3ChannelUpdateRhythm(chip *Chip, data uint8) { + chip.rhy = data & 0x3f + if chip.rhy&0x20 != 0 { + channel6 := &chip.channel[6] + channel7 := &chip.channel[7] + channel8 := &chip.channel[8] + channel6.out[0] = &channel6.slotz[1].out + channel6.out[1] = &channel6.slotz[1].out + channel6.out[2] = &chip.zeromod + channel6.out[3] = &chip.zeromod + channel6.outCnt = 2 + channel7.out[0] = &channel7.slotz[0].out + channel7.out[1] = &channel7.slotz[0].out + channel7.out[2] = &channel7.slotz[1].out + channel7.out[3] = &channel7.slotz[1].out + channel7.outCnt = 4 + channel8.out[0] = &channel8.slotz[0].out + channel8.out[1] = &channel8.slotz[0].out + channel8.out[2] = &channel8.slotz[1].out + channel8.out[3] = &channel8.slotz[1].out + channel8.outCnt = 4 + for chnum := 6; chnum < 9; chnum++ { + chip.channel[chnum].chtype = chDrum + } + opl3ChannelSetupAlg(channel6) + opl3ChannelSetupAlg(channel7) + opl3ChannelSetupAlg(channel8) + // hh + if chip.rhy&0x01 != 0 { + opl3EnvelopeKeyOn(channel7.slotz[0], egkDrum) + } else { + opl3EnvelopeKeyOff(channel7.slotz[0], egkDrum) + } + // tc + if chip.rhy&0x02 != 0 { + opl3EnvelopeKeyOn(channel8.slotz[1], egkDrum) + } else { + opl3EnvelopeKeyOff(channel8.slotz[1], egkDrum) + } + // tom + if chip.rhy&0x04 != 0 { + opl3EnvelopeKeyOn(channel8.slotz[0], egkDrum) + } else { + opl3EnvelopeKeyOff(channel8.slotz[0], egkDrum) + } + // sd + if chip.rhy&0x08 != 0 { + opl3EnvelopeKeyOn(channel7.slotz[1], egkDrum) + } else { + opl3EnvelopeKeyOff(channel7.slotz[1], egkDrum) + } + // bd + if chip.rhy&0x10 != 0 { + opl3EnvelopeKeyOn(channel6.slotz[0], egkDrum) + opl3EnvelopeKeyOn(channel6.slotz[1], egkDrum) + } else { + opl3EnvelopeKeyOff(channel6.slotz[0], egkDrum) + opl3EnvelopeKeyOff(channel6.slotz[1], egkDrum) + } + } else { + for chnum := 6; chnum < 9; chnum++ { + chip.channel[chnum].chtype = ch2op + opl3ChannelSetupAlg(&chip.channel[chnum]) + opl3EnvelopeKeyOff(chip.channel[chnum].slotz[0], egkDrum) + opl3EnvelopeKeyOff(chip.channel[chnum].slotz[1], egkDrum) + } + } +} + +func opl3ChannelWriteA0(channel *channel, data uint8) { + if channel.chip.newm != 0 && channel.chtype == ch4op2 { + return + } + channel.fNum = (channel.fNum & 0x300) | uint16(data) + channel.ksv = (channel.block << 1) | + uint8((channel.fNum>>(0x09-channel.chip.nts))&0x01) + opl3EnvelopeUpdateKSL(channel.slotz[0]) + opl3EnvelopeUpdateKSL(channel.slotz[1]) + opl3EnvelopeUpdateRate(channel.slotz[0]) + opl3EnvelopeUpdateRate(channel.slotz[1]) + opl3PhaseUpdateInc(channel.slotz[0]) + opl3PhaseUpdateInc(channel.slotz[1]) + if channel.chip.newm != 0 && channel.chtype == ch4op { + channel.pair.fNum = channel.fNum + channel.pair.ksv = channel.ksv + opl3EnvelopeUpdateKSL(channel.pair.slotz[0]) + opl3EnvelopeUpdateKSL(channel.pair.slotz[1]) + opl3EnvelopeUpdateRate(channel.pair.slotz[0]) + opl3EnvelopeUpdateRate(channel.pair.slotz[1]) + opl3PhaseUpdateInc(channel.pair.slotz[0]) + opl3PhaseUpdateInc(channel.pair.slotz[1]) + } +} + +func opl3ChannelWriteB0(channel *channel, data uint8) { + if channel.chip.newm != 0 && channel.chtype == ch4op2 { + return + } + channel.fNum = (channel.fNum & 0xff) | (uint16(data&0x03) << 8) + channel.block = (data >> 2) & 0x07 + channel.ksv = (channel.block << 1) | + uint8((channel.fNum>>(0x09-channel.chip.nts))&0x01) + opl3EnvelopeUpdateKSL(channel.slotz[0]) + opl3EnvelopeUpdateKSL(channel.slotz[1]) + opl3EnvelopeUpdateRate(channel.slotz[0]) + opl3EnvelopeUpdateRate(channel.slotz[1]) + opl3PhaseUpdateInc(channel.slotz[0]) + opl3PhaseUpdateInc(channel.slotz[1]) + if channel.chip.newm != 0 && channel.chtype == ch4op { + channel.pair.fNum = channel.fNum + channel.pair.block = channel.block + channel.pair.ksv = channel.ksv + opl3EnvelopeUpdateKSL(channel.pair.slotz[0]) + opl3EnvelopeUpdateKSL(channel.pair.slotz[1]) + opl3EnvelopeUpdateRate(channel.pair.slotz[0]) + opl3EnvelopeUpdateRate(channel.pair.slotz[1]) + opl3PhaseUpdateInc(channel.pair.slotz[0]) + opl3PhaseUpdateInc(channel.pair.slotz[1]) + } +} + +func opl3ChannelSetupAlg(channel *channel) { + if channel.chtype == chDrum { + if channel.chNum == 7 || channel.chNum == 8 { + channel.slotz[0].mod = &channel.chip.zeromod + channel.slotz[1].mod = &channel.chip.zeromod + return + } + switch channel.alg & 0x01 { + case 0x00: + channel.slotz[0].mod = &channel.slotz[0].fbmod + channel.slotz[1].mod = &channel.slotz[0].out + case 0x01: + channel.slotz[0].mod = &channel.slotz[0].fbmod + channel.slotz[1].mod = &channel.chip.zeromod + } + return + } + if channel.alg&0x08 != 0 { + return + } + if channel.alg&0x04 != 0 { + channel.pair.out[0] = &channel.chip.zeromod + channel.pair.out[1] = &channel.chip.zeromod + channel.pair.out[2] = &channel.chip.zeromod + channel.pair.out[3] = &channel.chip.zeromod + channel.pair.outCnt = 0 + switch channel.alg & 0x03 { + case 0x00: + channel.pair.slotz[0].mod = &channel.pair.slotz[0].fbmod + channel.pair.slotz[1].mod = &channel.pair.slotz[0].out + channel.slotz[0].mod = &channel.pair.slotz[1].out + channel.slotz[1].mod = &channel.slotz[0].out + channel.out[0] = &channel.slotz[1].out + channel.out[1] = &channel.chip.zeromod + channel.out[2] = &channel.chip.zeromod + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 1 + case 0x01: + channel.pair.slotz[0].mod = &channel.pair.slotz[0].fbmod + channel.pair.slotz[1].mod = &channel.pair.slotz[0].out + channel.slotz[0].mod = &channel.chip.zeromod + channel.slotz[1].mod = &channel.slotz[0].out + channel.out[0] = &channel.pair.slotz[1].out + channel.out[1] = &channel.slotz[1].out + channel.out[2] = &channel.chip.zeromod + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 2 + case 0x02: + channel.pair.slotz[0].mod = &channel.pair.slotz[0].fbmod + channel.pair.slotz[1].mod = &channel.chip.zeromod + channel.slotz[0].mod = &channel.pair.slotz[1].out + channel.slotz[1].mod = &channel.slotz[0].out + channel.out[0] = &channel.pair.slotz[0].out + channel.out[1] = &channel.slotz[1].out + channel.out[2] = &channel.chip.zeromod + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 2 + case 0x03: + channel.pair.slotz[0].mod = &channel.pair.slotz[0].fbmod + channel.pair.slotz[1].mod = &channel.chip.zeromod + channel.slotz[0].mod = &channel.pair.slotz[1].out + channel.slotz[1].mod = &channel.chip.zeromod + channel.out[0] = &channel.pair.slotz[0].out + channel.out[1] = &channel.slotz[0].out + channel.out[2] = &channel.slotz[1].out + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 3 + } + } else { + switch channel.alg & 0x01 { + case 0x00: + channel.slotz[0].mod = &channel.slotz[0].fbmod + channel.slotz[1].mod = &channel.slotz[0].out + channel.out[0] = &channel.slotz[1].out + channel.out[1] = &channel.chip.zeromod + channel.out[2] = &channel.chip.zeromod + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 1 + case 0x01: + channel.slotz[0].mod = &channel.slotz[0].fbmod + channel.slotz[1].mod = &channel.chip.zeromod + channel.out[0] = &channel.slotz[0].out + channel.out[1] = &channel.slotz[1].out + channel.out[2] = &channel.chip.zeromod + channel.out[3] = &channel.chip.zeromod + channel.outCnt = 2 + } + } +} + +func opl3ChannelUpdateAlg(channel *channel) { + channel.alg = channel.con + if channel.chip.newm != 0 { + if channel.chtype == ch4op { + channel.pair.alg = 0x04 | (channel.con << 1) | channel.pair.con + channel.alg = 0x08 + opl3ChannelSetupAlg(channel.pair) + } else if channel.chtype == ch4op2 { + channel.alg = 0x04 | (channel.pair.con << 1) | channel.con + channel.pair.alg = 0x08 + opl3ChannelSetupAlg(channel) + } else { + opl3ChannelSetupAlg(channel) + } + } else { + opl3ChannelSetupAlg(channel) + } +} + +func opl3ChannelWriteC0(channel *channel, data uint8) { + channel.fb = (data & 0x0e) >> 1 + channel.con = data & 0x01 + opl3ChannelUpdateAlg(channel) + if channel.chip.newm != 0 { + if (data>>4)&0x01 != 0 { + channel.cha = 0xffff + } else { + channel.cha = 0 + } + if (data>>5)&0x01 != 0 { + channel.chb = 0xffff + } else { + channel.chb = 0 + } + if (data>>6)&0x01 != 0 { + channel.chc = 0xffff + } else { + channel.chc = 0 + } + if (data>>7)&0x01 != 0 { + channel.chd = 0xffff + } else { + channel.chd = 0 + } + } else { + channel.cha = 0xffff + channel.chb = 0xffff + channel.chc = 0 + channel.chd = 0 + } +} + +func opl3ChannelKeyOn(channel *channel) { + if channel.chip.newm != 0 { + if channel.chtype == ch4op { + opl3EnvelopeKeyOn(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOn(channel.slotz[1], egkNorm) + opl3EnvelopeKeyOn(channel.pair.slotz[0], egkNorm) + opl3EnvelopeKeyOn(channel.pair.slotz[1], egkNorm) + } else if channel.chtype == ch2op || channel.chtype == chDrum { + opl3EnvelopeKeyOn(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOn(channel.slotz[1], egkNorm) + } + } else { + opl3EnvelopeKeyOn(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOn(channel.slotz[1], egkNorm) + } +} + +func opl3ChannelKeyOff(channel *channel) { + if channel.chip.newm != 0 { + if channel.chtype == ch4op { + opl3EnvelopeKeyOff(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOff(channel.slotz[1], egkNorm) + opl3EnvelopeKeyOff(channel.pair.slotz[0], egkNorm) + opl3EnvelopeKeyOff(channel.pair.slotz[1], egkNorm) + } else if channel.chtype == ch2op || channel.chtype == chDrum { + opl3EnvelopeKeyOff(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOff(channel.slotz[1], egkNorm) + } + } else { + opl3EnvelopeKeyOff(channel.slotz[0], egkNorm) + opl3EnvelopeKeyOff(channel.slotz[1], egkNorm) + } +} + +func opl3ChannelSet4Op(chip *Chip, data uint8) { + for bit := uint8(0); bit < 6; bit++ { + chnum := bit + if bit >= 3 { + chnum += 9 - 3 + } + if (data>>bit)&0x01 != 0 { + chip.channel[chnum].chtype = ch4op + chip.channel[chnum+3].chtype = ch4op2 + opl3ChannelUpdateAlg(&chip.channel[chnum]) + } else { + chip.channel[chnum].chtype = ch2op + chip.channel[chnum+3].chtype = ch2op + opl3ChannelUpdateAlg(&chip.channel[chnum]) + opl3ChannelUpdateAlg(&chip.channel[chnum+3]) + } + } +} + +func opl3ClipSample(sample int32) int16 { + if sample > 32767 { + sample = 32767 + } else if sample < -32768 { + sample = -32768 + } + return int16(sample) +} + +func opl3ProcessSlot(s *slot) { + // Fast path for fully-attenuated key-off non-rhythm slots. + if s.key == 0 && s.egRout == 0x1ff && + s.slotNum != 13 && s.slotNum != 16 && s.slotNum != 17 { + chip := s.chip + noise := chip.noise + nBit := uint8((noise>>14)^noise) & 0x01 + + if s.channel.fb == 0 && s.pgInc == 0 && s.out == 0 && + *s.mod == 0 && s.egTlKsl == 0 && *s.trem == 0 && + s.pgPhase == 0 && s.regVib == 0 && s.regWf == 0 { + s.fbmod = 0 + s.prout = 0 + s.egOut = 0x1ff + s.pgReset = 0 + s.egGen = envRelease + s.pgPhaseOut = 0 + chip.noise = (noise >> 1) | (uint32(nBit) << 22) + return + } + + opl3SlotCalcFB(s) + + s.egOut = s.egRout + s.egTlKsl + uint16(*s.trem) + s.pgReset = 0 + s.egGen = envRelease + + var phaseinc uint32 + if s.regVib != 0 { + fNum := s.channel.fNum + rng := int8((fNum >> 7) & 7) + vibpos := chip.vibpos + + if vibpos&3 == 0 { + rng = 0 + } else if vibpos&1 != 0 { + rng >>= 1 + } + rng >>= chip.vibshift + + if vibpos&4 != 0 { + rng = -rng + } + fNum += uint16(rng) + phaseinc = ((uint32(fNum) << s.channel.block) >> 1) * + uint32(mt[s.regMult]) >> 1 + } else { + phaseinc = s.pgInc + } + + phase := uint16(s.pgPhase >> 9) + s.pgPhase += phaseinc + s.pgPhaseOut = phase + chip.noise = (noise >> 1) | (uint32(nBit) << 22) + + opl3SlotGenerateSilent(s) + return + } + if s.egGen == envSustain && s.key != 0 && s.egRates[envSustain] == 0 { + opl3SlotCalcFB(s) + s.egOut = s.egRout + s.egTlKsl + uint16(*s.trem) + s.pgReset = 0 + if (s.egRout & 0x1f8) == 0x1f8 { + s.egRout = 0x1ff + } + + if s.regVib == 0 && + s.slotNum != 13 && s.slotNum != 16 && s.slotNum != 17 { + chip := s.chip + noise := chip.noise + nBit := uint8((noise>>14)^noise) & 0x01 + phase := uint16(s.pgPhase >> 9) + + s.pgPhase += s.pgInc + s.pgPhaseOut = phase + chip.noise = (noise >> 1) | (uint32(nBit) << 22) + } else { + opl3PhaseGenerate(s) + } + + opl3SlotGenerate(s) + return + } + opl3SlotCalcFB(s) + opl3EnvelopeCalc(s) + opl3PhaseGenerate(s) + opl3SlotGenerate(s) +} + +func opl3Generate4Ch(chip *Chip, buf4 []int16) { + var mix [2]int32 + var accm int16 + + buf4[1] = opl3ClipSample(chip.mixbuff[1]) + buf4[3] = opl3ClipSample(chip.mixbuff[3]) + + // OPL_QUIRK_CHANNELSAMPLEDELAY == 1 + for ii := 0; ii < 15; ii++ { + opl3ProcessSlot(&chip.slot[ii]) + } + + mix[0], mix[1] = 0, 0 + for ii := 0; ii < 18; ii++ { + ch := &chip.channel[ii] + if ch.outCnt == 0 { + continue + } + if ch.cha|ch.chc == 0 { + continue + } + out := &ch.out + accm = *out[0] + if ch.outCnt > 1 { + accm += *out[1] + if ch.outCnt > 2 { + accm += *out[2] + if ch.outCnt > 3 { + accm += *out[3] + } + } + } + mix[0] += int32(int16(uint16(accm) & ch.cha)) + mix[1] += int32(int16(uint16(accm) & ch.chc)) + } + chip.mixbuff[0] = mix[0] + chip.mixbuff[2] = mix[1] + + for ii := 15; ii < 18; ii++ { + opl3ProcessSlot(&chip.slot[ii]) + } + + buf4[0] = opl3ClipSample(chip.mixbuff[0]) + buf4[2] = opl3ClipSample(chip.mixbuff[2]) + + for ii := 18; ii < 33; ii++ { + opl3ProcessSlot(&chip.slot[ii]) + } + + mix[0], mix[1] = 0, 0 + for ii := 0; ii < 18; ii++ { + ch := &chip.channel[ii] + if ch.outCnt == 0 { + continue + } + out := &ch.out + accm = *out[0] + if ch.outCnt > 1 { + accm += *out[1] + if ch.outCnt > 2 { + accm += *out[2] + if ch.outCnt > 3 { + accm += *out[3] + } + } + } + mix[0] += int32(int16(uint16(accm) & ch.chb)) + mix[1] += int32(int16(uint16(accm) & ch.chd)) + } + chip.mixbuff[1] = mix[0] + chip.mixbuff[3] = mix[1] + + for ii := 33; ii < 36; ii++ { + opl3ProcessSlot(&chip.slot[ii]) + } + + updateTremolo := chip.tremoloDirty + if (chip.timer & 0x3f) == 0x3f { + chip.tremolopos++ + if chip.tremolopos == 210 { + chip.tremolopos = 0 + } + updateTremolo = 1 + } + if updateTremolo != 0 { + if chip.tremolopos < 105 { + chip.tremolo = chip.tremolopos >> chip.tremoloshift + } else { + chip.tremolo = (210 - chip.tremolopos) >> chip.tremoloshift + } + chip.tremoloDirty = 0 + } + + if (chip.timer & 0x3ff) == 0x3ff { + chip.vibpos = (chip.vibpos + 1) & 7 + } + + chip.timer++ + + if chip.egState != 0 { + egTimerLow := uint32(chip.egTimer) & 0x1fff + if egTimerLow == 0 { + chip.egAdd = 0 + } else { + shift := uint8(bits.TrailingZeros32(egTimerLow)) + chip.egAdd = shift + 1 + } + chip.egTimerLo = uint8(chip.egTimer & 0x3) + } + + if chip.egTimerrem != 0 || chip.egState != 0 { + if chip.egTimer == 0xfffffffff { + chip.egTimer = 0 + chip.egTimerrem = 1 + } else { + chip.egTimer++ + chip.egTimerrem = 0 + } + } + + chip.egState ^= 1 + + for { + wb := &chip.writebuf[chip.writebufCur] + if wb.time > chip.writebufSamplecnt { + break + } + if wb.reg&0x200 == 0 { + break + } + wb.reg &= 0x1ff + chip.WriteReg(wb.reg, wb.data) + chip.writebufCur = (chip.writebufCur + 1) % writebufSize + } + chip.writebufSamplecnt++ +} + +func opl3Generate(chip *Chip, buf []int16) { + var samples [4]int16 + opl3Generate4Ch(chip, samples[:]) + buf[0] = samples[0] + buf[1] = samples[1] +} + +func opl3Generate4ChResampled(chip *Chip, buf4 []int16) { + for chip.samplecnt >= chip.rateratio { + chip.oldsamples[0] = chip.samples[0] + chip.oldsamples[1] = chip.samples[1] + chip.oldsamples[2] = chip.samples[2] + chip.oldsamples[3] = chip.samples[3] + opl3Generate4Ch(chip, chip.samples[:]) + chip.samplecnt -= chip.rateratio + } + buf4[0] = int16((int32(chip.oldsamples[0])*(chip.rateratio-chip.samplecnt) + + int32(chip.samples[0])*chip.samplecnt) / chip.rateratio) + buf4[1] = int16((int32(chip.oldsamples[1])*(chip.rateratio-chip.samplecnt) + + int32(chip.samples[1])*chip.samplecnt) / chip.rateratio) + buf4[2] = int16((int32(chip.oldsamples[2])*(chip.rateratio-chip.samplecnt) + + int32(chip.samples[2])*chip.samplecnt) / chip.rateratio) + buf4[3] = int16((int32(chip.oldsamples[3])*(chip.rateratio-chip.samplecnt) + + int32(chip.samples[3])*chip.samplecnt) / chip.rateratio) + chip.samplecnt += 1 << rsmFrac +} + +func opl3GenerateResampled(chip *Chip, buf []int16) { + var samples [4]int16 + opl3Generate4ChResampled(chip, samples[:]) + buf[0] = samples[0] + buf[1] = samples[1] +} + +func opl3Reset(chip *Chip, samplerate uint32) { + *chip = Chip{} + for slotnum := uint8(0); slotnum < 36; slotnum++ { + s := &chip.slot[slotnum] + s.chip = chip + s.mod = &chip.zeromod + s.egRout = 0x1ff + s.egOut = 0x1ff + s.egGen = envRelease + s.trem = &chip.zeroTrem + s.egRates[0], s.egRates[1], s.egRates[2], s.egRates[3] = 0, 0, 0, 0 + s.slotNum = slotnum + } + for channum := uint8(0); channum < 18; channum++ { + ch := &chip.channel[channum] + localChSlot := chSlot[channum] + ch.slotz[0] = &chip.slot[localChSlot] + ch.slotz[1] = &chip.slot[localChSlot+3] + chip.slot[localChSlot].channel = ch + chip.slot[localChSlot+3].channel = ch + if (channum % 9) < 3 { + ch.pair = &chip.channel[channum+3] + } else if (channum % 9) < 6 { + ch.pair = &chip.channel[channum-3] + } + ch.chip = chip + ch.out[0] = &chip.zeromod + ch.out[1] = &chip.zeromod + ch.out[2] = &chip.zeromod + ch.out[3] = &chip.zeromod + ch.outCnt = 0 + ch.chtype = ch2op + ch.cha = 0xffff + ch.chb = 0xffff + ch.chNum = channum + opl3ChannelSetupAlg(ch) + } + chip.noise = 1 + chip.rateratio = int32((samplerate << rsmFrac) / 49716) + chip.tremoloshift = 4 + chip.vibshift = 1 +} + +func opl3WriteReg(chip *Chip, reg uint16, v uint8) { + high := uint8((reg >> 8) & 0x01) + regm := reg & 0xff + switch regm & 0xf0 { + case 0x00: + if high != 0 { + switch regm & 0x0f { + case 0x04: + opl3ChannelSet4Op(chip, v) + case 0x05: + chip.newm = v & 0x01 + } + } else { + switch regm & 0x0f { + case 0x08: + chip.nts = (v >> 6) & 0x01 + } + } + case 0x20, 0x30: + if adSlot[regm&0x1f] >= 0 { + opl3SlotWrite20(&chip.slot[18*uint16(high)+uint16(adSlot[regm&0x1f])], v) + } + case 0x40, 0x50: + if adSlot[regm&0x1f] >= 0 { + opl3SlotWrite40(&chip.slot[18*uint16(high)+uint16(adSlot[regm&0x1f])], v) + } + case 0x60, 0x70: + if adSlot[regm&0x1f] >= 0 { + opl3SlotWrite60(&chip.slot[18*uint16(high)+uint16(adSlot[regm&0x1f])], v) + } + case 0x80, 0x90: + if adSlot[regm&0x1f] >= 0 { + opl3SlotWrite80(&chip.slot[18*uint16(high)+uint16(adSlot[regm&0x1f])], v) + } + case 0xe0, 0xf0: + if adSlot[regm&0x1f] >= 0 { + opl3SlotWriteE0(&chip.slot[18*uint16(high)+uint16(adSlot[regm&0x1f])], v) + } + case 0xa0: + if (regm & 0x0f) < 9 { + opl3ChannelWriteA0(&chip.channel[9*uint16(high)+(regm&0x0f)], v) + } + case 0xb0: + if regm == 0xbd && high == 0 { + tremoloshift := (((v >> 7) ^ 1) << 1) + 2 + if chip.tremoloshift != tremoloshift { + chip.tremoloDirty = 1 + } + chip.tremoloshift = tremoloshift + chip.vibshift = ((v >> 6) & 0x01) ^ 1 + opl3ChannelUpdateRhythm(chip, v) + } else if (regm & 0x0f) < 9 { + opl3ChannelWriteB0(&chip.channel[9*uint16(high)+(regm&0x0f)], v) + if v&0x20 != 0 { + opl3ChannelKeyOn(&chip.channel[9*uint16(high)+(regm&0x0f)]) + } else { + opl3ChannelKeyOff(&chip.channel[9*uint16(high)+(regm&0x0f)]) + } + } + case 0xc0: + if (regm & 0x0f) < 9 { + opl3ChannelWriteC0(&chip.channel[9*uint16(high)+(regm&0x0f)], v) + } + } +} + +func opl3WriteRegBuffered(chip *Chip, reg uint16, v uint8) { + writebufLast := chip.writebufLast + wb := &chip.writebuf[writebufLast] + + if wb.reg&0x200 != 0 { + chip.WriteReg(wb.reg&0x1ff, wb.data) + + chip.writebufCur = (writebufLast + 1) % writebufSize + chip.writebufSamplecnt = wb.time + } + + wb.reg = reg | 0x200 + wb.data = v + time1 := chip.writebufLasttime + writebufDelay + time2 := chip.writebufSamplecnt + + if time1 < time2 { + time1 = time2 + } + + wb.time = time1 + chip.writebufLasttime = time1 + chip.writebufLast = (writebufLast + 1) % writebufSize +} + +func opl3GenerateStream(chip *Chip, sndptr []int16, numsamples uint32) { + off := 0 + for i := uint32(0); i < numsamples; i++ { + opl3GenerateResampled(chip, sndptr[off:]) + off += 2 + } +} + +// +// Public API +// + +// NewChip allocates and resets an OPL3 chip configured for the given output +// sample rate (Hz). It is the equivalent of OPL3_Reset on a fresh chip. +func NewChip(sampleRate uint32) *Chip { + c := &Chip{} + opl3Reset(c, sampleRate) + return c +} + +// Reset re-initializes the chip for the given output sample rate (Hz). +// Equivalent to OPL3_Reset. +func (c *Chip) Reset(sampleRate uint32) { + opl3Reset(c, sampleRate) +} + +// WriteReg applies a register write immediately. Equivalent to OPL3_WriteReg. +func (c *Chip) WriteReg(reg uint16, val uint8) { + opl3WriteReg(c, reg, val) +} + +// WriteRegBuffered queues a register write to take effect after the +// OPL_WRITEBUF_DELAY sample delay honored by the chip's write buffer. +// Equivalent to OPL3_WriteRegBuffered. +func (c *Chip) WriteRegBuffered(reg uint16, val uint8) { + opl3WriteRegBuffered(c, reg, val) +} + +// Generate produces one stereo frame of unresampled output, writing two +// int16 samples (left, right) into buf (len(buf) must be >= 2). +// Equivalent to OPL3_Generate. +func (c *Chip) Generate(buf []int16) { + opl3Generate(c, buf) +} + +// GenerateResampled produces one stereo frame resampled to the chip's +// configured output rate, writing two int16 samples (left, right) into buf +// (len(buf) must be >= 2). Equivalent to OPL3_GenerateResampled. +func (c *Chip) GenerateResampled(buf []int16) { + opl3GenerateResampled(c, buf) +} + +// GenerateStream produces numFrames stereo frames (2*numFrames int16 +// samples) of resampled output into buf. Equivalent to OPL3_GenerateStream. +func (c *Chip) GenerateStream(buf []int16, numFrames int) { + opl3GenerateStream(c, buf, uint32(numFrames)) +} diff --git a/music/opl/opl3_internal_test.go b/music/opl/opl3_internal_test.go new file mode 100644 index 0000000..e7ac128 --- /dev/null +++ b/music/opl/opl3_internal_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Go port of Nuked OPL3 (github.com/nukeykt/Nuked-OPL3) by Nuke.YKT. +// Ported for the go-doom/engine authors. Original C: LGPL-2.1-or-later. + +package opl + +import "testing" + +// TestResetMethod exercises the exported Reset method directly (as opposed to +// NewChip) and confirms it yields the same bit-exact output as the golden. +func TestResetMethod(t *testing.T) { + want := loadGolden(t, "golden_stream.pcm") + c := &Chip{} + c.Reset(22050) // wrong rate + dirty state + for i := 0; i < 40; i++ { + c.WriteReg(uint16(0x20+i), uint8(i*7)) + } + buf := make([]int16, 64) + c.GenerateStream(buf, 32) + + // Now Reset to the oracle rate and replay the immediate-write script. + c.Reset(49716) + out := make([]int16, 0, len(want)) + l := lcg{state: 0xC0FFEE11} + frame := make([]int16, 2) + gen := func() { + c.GenerateStream(frame, 1) + out = append(out, frame[0], frame[1]) + } + for i := range proReg { + c.WriteReg(proReg[i], proVal[i]) + } + for i := 0; i < 2000; i++ { + gen() + } + c.WriteReg(0x0b0, 0x0a) + for i := 0; i < 500; i++ { + gen() + } + for r := 0; r < 300; r++ { + nw := 1 + ((l.next() >> 28) & 7) + for k := uint32(0); k < nw; k++ { + x := l.next() + c.WriteReg(uint16(x&0x1ff), uint8((x>>9)&0xff)) + } + frames := 4 + ((l.next() >> 26) & 0x3f) + for k := uint32(0); k < frames; k++ { + gen() + } + } + assertBitExact(t, "Reset-method replay", out, want) +} + +// TestClipSample covers both saturation branches and the pass-through of +// OPL3_ClipSample. +func TestClipSample(t *testing.T) { + cases := []struct { + in int32 + want int16 + }{ + {0, 0}, + {100, 100}, + {-100, -100}, + {32767, 32767}, + {-32768, -32768}, + {32768, 32767}, // positive saturation + {999999, 32767}, // positive saturation + {-32769, -32768}, // negative saturation + {-999999, -32768}, + } + for _, c := range cases { + if got := opl3ClipSample(c.in); got != c.want { + t.Errorf("opl3ClipSample(%d) = %d, want %d", c.in, got, c.want) + } + } +} + +// TestRhythmKeyPaths drives the percussion mode both fully on (KeyOn branches) +// and rhythm-enabled-but-drums-off (KeyOff branches), plus rhythm off. +func TestRhythmKeyPaths(t *testing.T) { + c := NewChip(49716) + c.WriteReg(0x105, 0x01) + // Give the rhythm operators an audible envelope. + for _, r := range []uint16{0x20, 0x23, 0x24, 0x25, 0x51, 0x52, 0x53, 0x54, 0x55} { + c.WriteReg(r, 0x01) + } + for _, r := range []uint16{0x60, 0x63, 0x64, 0x65, 0x80, 0x83, 0x84, 0x85} { + c.WriteReg(r, 0xf0) + } + buf := make([]int16, 256) + c.WriteReg(0xbd, 0x3f) // rhythm on + all 5 drums keyed on + c.GenerateStream(buf, 128) + c.WriteReg(0xbd, 0x20) // rhythm still on, every drum keyed off + c.GenerateStream(buf, 128) + c.WriteReg(0xbd, 0x00) // rhythm off + c.GenerateStream(buf, 128) +} + +// TestSetupAlgReturnBit3 covers the defensive early return in +// opl3ChannelSetupAlg for a channel whose alg has bit 3 set (0x08). In normal +// register flow this channel is a 4-op member whose setup is driven through +// its pair, so this guard is only reachable defensively; exercise it directly. +func TestSetupAlgReturnBit3(t *testing.T) { + c := NewChip(49716) + ch := &c.channel[0] + before := ch.out + ch.alg = 0x08 + opl3ChannelSetupAlg(ch) + if ch.out != before { + t.Errorf("SetupAlg with alg&0x08 modified routing: %v -> %v", before, ch.out) + } +} + +// TestTremoloPosWrap forces the tremolopos == 210 wrap branch in the +// per-sample housekeeping. +func TestTremoloPosWrap(t *testing.T) { + c := NewChip(49716) + c.tremolopos = 209 + c.timer = 0x3f // (timer & 0x3f) == 0x3f triggers the increment + buf := make([]int16, 2) + c.Generate(buf) + if c.tremolopos != 0 { + t.Errorf("tremolopos did not wrap: got %d, want 0", c.tremolopos) + } +} + +// TestEgTimerWrap forces the 36-bit envelope-timer wrap branch, which is not +// reachable within a practical test run (it takes ~2^36 samples of real time) +// but is deterministic when the state is set directly. +func TestEgTimerWrap(t *testing.T) { + c := NewChip(49716) + c.egTimer = 0xfffffffff + c.egTimerrem = 0 + c.egState = 1 + buf := make([]int16, 2) + c.Generate(buf) + if c.egTimer != 0 || c.egTimerrem != 1 { + t.Errorf("egTimer wrap not taken: egTimer=%#x egTimerrem=%d", c.egTimer, c.egTimerrem) + } +} + +// TestWriteBufferedRingReuse floods the write buffer with more pending +// buffered writes than the ring can hold without generating, forcing the +// branch that flushes the slot being overwritten (wb.reg & 0x200 set). +func TestWriteBufferedRingReuse(t *testing.T) { + c := NewChip(49716) + c.WriteReg(0x105, 0x01) + // writebufSize+64 buffered writes with no Generate in between: the ring + // wraps and OPL3_WriteRegBuffered must flush the stale entry. + for i := 0; i < writebufSize+64; i++ { + c.WriteRegBuffered(0x040, uint8(i&0x3f)) + } + // It must still generate without panicking and eventually drain. + buf := make([]int16, 4) + c.GenerateStream(buf, 2) +} diff --git a/music/opl/opl3_test.go b/music/opl/opl3_test.go new file mode 100644 index 0000000..6d5ed36 --- /dev/null +++ b/music/opl/opl3_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Go port of Nuked OPL3 (github.com/nukeykt/Nuked-OPL3) by Nuke.YKT. +// Ported for the go-doom/engine authors. Original C: LGPL-2.1-or-later. + +package opl + +import ( + "encoding/binary" + "os" + "testing" +) + +// lcg is the deterministic PRNG shared byte-for-byte with the C oracle +// harness (oploracle/harness.c). The wraparound of uint32 multiply/add +// matches the C unsigned arithmetic exactly. +type lcg struct{ state uint32 } + +func (l *lcg) next() uint32 { + l.state = l.state*1664525 + 1013904223 + return l.state +} + +// Structured "real note" prologue writes, identical to the C harness. +var ( + proReg = []uint16{ + 0x105, 0x104, + 0x020, 0x023, 0x040, 0x043, 0x060, 0x063, + 0x080, 0x083, 0x0e0, 0x0e3, 0x0c0, 0x0a0, 0x0b0, + } + proVal = []uint8{ + 0x01, 0x00, + 0x01, 0x01, 0x1a, 0x00, 0xf0, 0xf0, + 0x77, 0x77, 0x01, 0x02, 0x0e, 0x81, 0x2a, + } +) + +// runScript reproduces the exact register-write + generate sequence of the +// C oracle harness and returns the resulting interleaved stereo PCM. +// +// buffered selects WriteRegBuffered vs WriteReg; perframe selects the +// per-frame Generate vs GenerateStream generation path. +func runScript(buffered, perframe bool) []int16 { + c := NewChip(49716) + out := make([]int16, 0, 64*1024*2) + l := lcg{state: 0xC0FFEE11} + + frame := make([]int16, 2) + gen := func() { + if perframe { + c.Generate(frame) + } else { + c.GenerateStream(frame, 1) + } + out = append(out, frame[0], frame[1]) + } + write := func(reg uint16, val uint8) { + if buffered { + c.WriteRegBuffered(reg, val) + } else { + c.WriteReg(reg, val) + } + } + + for i := range proReg { + write(proReg[i], proVal[i]) + } + for i := 0; i < 2000; i++ { + gen() + } + write(0x0b0, 0x0a) + for i := 0; i < 500; i++ { + gen() + } + + for r := 0; r < 300; r++ { + nw := 1 + ((l.next() >> 28) & 7) + for k := uint32(0); k < nw; k++ { + x := l.next() + write(uint16(x&0x1ff), uint8((x>>9)&0xff)) + } + frames := 4 + ((l.next() >> 26) & 0x3f) + for k := uint32(0); k < frames; k++ { + gen() + } + } + return out +} + +func loadGolden(t *testing.T, name string) []int16 { + t.Helper() + raw, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + if len(raw)%2 != 0 { + t.Fatalf("golden %s: odd byte count %d", name, len(raw)) + } + s := make([]int16, len(raw)/2) + for i := range s { + s[i] = int16(binary.LittleEndian.Uint16(raw[2*i:])) + } + return s +} + +func assertBitExact(t *testing.T, name string, got, want []int16) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: sample count mismatch: got %d, want %d", name, len(got), len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s: sample %d mismatch (frame %d, %s): got %d, want %d", + name, i, i/2, chanName(i), got[i], want[i]) + } + } + t.Logf("%s: bit-exact over %d samples (%d stereo frames)", name, len(got), len(got)/2) +} + +func chanName(i int) string { + if i%2 == 0 { + return "L" + } + return "R" +} + +func TestBitExactStream(t *testing.T) { + want := loadGolden(t, "golden_stream.pcm") + got := runScript(false, false) + assertBitExact(t, "stream", got, want) +} + +func TestBitExactBuffered(t *testing.T) { + want := loadGolden(t, "golden_buffered.pcm") + got := runScript(true, false) + assertBitExact(t, "buffered", got, want) +} + +func TestBitExactGenerate(t *testing.T) { + want := loadGolden(t, "golden_gen.pcm") + got := runScript(false, true) + assertBitExact(t, "gen", got, want) +} + +// TestTableParity spot-checks a handful of known lookup-table entries against +// the values in the reference opl3.c / wf_rom.h. +func TestTableParity(t *testing.T) { + if exprom[0] != 0xff4 || exprom[255] != 0x800 { + t.Errorf("exprom endpoints: got %#x..%#x", exprom[0], exprom[255]) + } + if mt[0] != 1 || mt[10] != 20 || mt[15] != 30 { + t.Errorf("mt table: %v", mt) + } + if kslrom[0] != 0 || kslrom[15] != 64 { + t.Errorf("kslrom endpoints: %d..%d", kslrom[0], kslrom[15]) + } + if kslshift != [4]uint8{8, 1, 2, 0} { + t.Errorf("kslshift: %v", kslshift) + } + if chSlot[3] != 6 || chSlot[17] != 32 { + t.Errorf("chSlot: %v", chSlot) + } + if adSlot[6] != -1 || adSlot[0] != 0 || adSlot[21] != 17 { + t.Errorf("adSlot: %v", adSlot) + } + if egIncstep[3] != [4]uint8{1, 1, 1, 0} { + t.Errorf("egIncstep[3]: %v", egIncstep[3]) + } + // logsin waveform table endpoints (wf_rom.h, first/last rows). + if logsinWF[0][0] != 0x0859 || logsinWF[7][1023] != 0x8000 { + t.Errorf("logsinWF endpoints: %#x / %#x", logsinWF[0][0], logsinWF[7][1023]) + } +} + +// TestResetIdempotent verifies Reset restores a chip to the same state a +// fresh NewChip produces, and that a second identical script yields the same +// bit-exact output (no residual state). +func TestResetIdempotent(t *testing.T) { + want := loadGolden(t, "golden_stream.pcm") + c := NewChip(48000) + // drive some garbage + for i := 0; i < 50; i++ { + c.WriteReg(uint16(i), uint8(i*3)) + } + buf := make([]int16, 200) + c.GenerateStream(buf, 100) + // Reset back to the oracle rate and re-run: must match the golden. + got := runScript(false, false) + assertBitExact(t, "post-garbage rerun", got, want) + _ = want +} + +// TestGenerateResampledUpsample exercises the resampler with a non-native +// output rate (rateratio != 1<XNDj+%M93&|@=bWRGGYW!85>P;b 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 literal 0 HcmV?d00001 diff --git a/music/opl/testdata/golden_stream.pcm b/music/opl/testdata/golden_stream.pcm new file mode 100644 index 0000000000000000000000000000000000000000..de8789a8c5a9a9f6b2fc739724b91c6077af7a60 GIT binary patch literal 50496 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 skr?j!PQy=4=Xr)-@!7$sg+ 9 swap on a controller event. + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 15, Param1: 0x07, Param2: 90}) + + // Percussion: in range, out of range (ignored), and via the 9<->15 swap. + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 9, Param1: 38, Param2: 110}) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 9, Param1: 20, Param2: 110}) // key < 35: ignored + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 9, Param1: 90, Param2: 110}) // key > 81: ignored + + // A note-on with velocity 0 is a note-off. + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 64, Param2: 60}) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 64, Param2: 0}) + + // All notes off. + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 67, Param2: 80}) + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 0, Param1: 0x7B}) + + // Tempo change (adjusts queued callbacks via float32 rescaling). + p.processEvent(&midi.Event{Type: midi.Meta, MetaType: midi.MetaSetTempo, Data: []byte{0x07, 0xA1, 0x20}}) + // Unknown/ignored controller and meta types. + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 0, Param1: 0x01, Param2: 10}) + p.processEvent(&midi.Event{Type: midi.Meta, MetaType: midi.MetaTrackName, Data: []byte("x")}) + // SysEx events are ignored. + p.processEvent(&midi.Event{Type: midi.SysEx}) +} + +// findFeedbackInstrument returns a melodic instrument index whose primary voice +// uses non-modulated feedback with a modulator level below maximum, so that the +// extra volume-register write in setVoiceVolume is taken. +func findFeedbackInstrument(p *Player) int { + for i := 0; i < 128; i++ { + v := p.bank.Instruments[i].Voices[0] + if v.Feedback&0x01 != 0 && v.Modulator.Level != 0x3f { + return i + } + } + return 0 +} + +// TestVoiceReplacementDoom19 forces the OPL2 (9 voice) freelist to empty so +// ReplaceExistingVoice runs. +func TestVoiceReplacementDoom19(t *testing.T) { + p := newTestPlayer(t, false) + for key := 40; key < 60; key++ { // more than 9 simultaneous notes + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: uint8(key), Param2: 90}) + } + // getFreeVoice returning nil: force the freelist empty and try one more. + p.freeNum = 0 + p.voiceKeyOn(&p.channels[0], 0, 0, 60, 60, 90) +} + +// TestVoiceReplacementDoom1 covers the Doom 1 v1.666 driver quirks, including +// the double-voice release recursion. +func TestVoiceReplacementDoom1(t *testing.T) { + p := newTestPlayer(t, true) + p.oplDrvVer = drvDoom1_1_666 + + dv := findDoubleVoiceInstrument(p) + p.processEvent(&midi.Event{Type: midi.ProgramChange, Channel: 1, Param1: uint8(dv)}) + for key := 30; key < 60; key++ { + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 1, Param1: uint8(key), Param2: 90}) + } + // Release them (double-voice release recursion for drv < 1.9). + for key := 30; key < 60; key++ { + p.processEvent(&midi.Event{Type: midi.NoteOff, Channel: 1, Param1: uint8(key)}) + } + + // Non-OPL3 forces voicenum to 1 in the Doom 1 path. + p.opl3mode = false + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 1, Param1: 61, Param2: 90}) +} + +// TestVoiceReplacementDoom2 covers the Doom 2 v1.666 driver replacement path. +func TestVoiceReplacementDoom2(t *testing.T) { + p := newTestPlayer(t, true) + p.oplDrvVer = drvDoom2_1_666 + + dv := findDoubleVoiceInstrument(p) + p.processEvent(&midi.Event{Type: midi.ProgramChange, Channel: 2, Param1: uint8(dv)}) + for key := 30; key < 65; key++ { + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 2, Param1: uint8(key), Param2: 90}) + } +} + +func findDoubleVoiceInstrument(p *Player) int { + for i := 0; i < 128; i++ { + if p.bank.Instruments[i].Flags&0x0004 != 0 { + return i + } + } + return 0 +} + +// TestPauseResumeStopVolume covers the transport controls and volume handling. +func TestPauseResumeStopVolume(t *testing.T) { + p := newTestPlayer(t, false) + + // Play a couple of notes (main + percussion) so Pause has voices to key off. + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 60, Param2: 100}) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 9, Param1: 40, Param2: 100}) + + p.SetVolume(90) // change volume: exercises setMusicVolume channel loop + p.SetVolume(90) // no change: early return + p.SetVolume(64) + + p.Pause() + buf := make([]int16, 256) + p.Read(buf) // paused render path (advanceTime pause branch) + p.Resume() + + p.Stop() + if p.IsPlaying() { + t.Fatal("IsPlaying true after Stop") + } +} + +// TestRestartSong covers restartSong directly and the queue clear/peek paths. +func TestRestartSong(t *testing.T) { + p := newTestPlayer(t, false) + p.restartSong() + if p.runningTracks != p.numTracks { + t.Fatalf("runningTracks=%d, want %d", p.runningTracks, p.numTracks) + } +} + +// TestLoopViaTrace runs the scheduler long enough for the song to reach +// END_OF_TRACK and loop, exercising the end-of-track restart callback. +func TestLoopViaTrace(t *testing.T) { + p, err := newPlayer(mustRead(t, genmidiPath), 44100, false, false) + if err != nil { + t.Fatalf("newPlayer: %v", err) + } + if err := p.RegisterSong(mustRead(t, dintroPath)); err != nil { + t.Fatalf("RegisterSong: %v", err) + } + p.Play(true) + // D_INTRO is short; 120 simulated seconds is several loops. + p.runTrace(120_000_000) +} + +// TestChannelIndexDefensive covers the not-found branch of channelIndex. +func TestChannelIndexDefensive(t *testing.T) { + p := newTestPlayer(t, false) + if got := p.channelIndex(&channelData{}); got != -1 { + t.Fatalf("channelIndex(foreign) = %d, want -1", got) + } +} + +// TestEmptyQueuePeek covers peek on an empty queue. +func TestEmptyQueuePeek(t *testing.T) { + q := newQueue() + if q.peek() != 0 { + t.Fatal("empty queue peek should be 0") + } + if !q.isEmpty() { + t.Fatal("new queue should be empty") + } +} + +// TestPlayWithoutSong covers the early return in Play when no song is loaded. +func TestPlayWithoutSong(t *testing.T) { + p, err := New(mustRead(t, genmidiPath), 44100, false) + if err != nil { + t.Fatalf("New: %v", err) + } + p.Play(true) // no RegisterSong: should be a no-op + if p.IsPlaying() { + t.Fatal("IsPlaying true without a registered song") + } +} + +// TestQueueOverflow covers the max-callbacks drop path in the queue. +func TestQueueOverflow(t *testing.T) { + q := newQueue() + for i := 0; i < maxOPLQueue+5; i++ { + q.push(queueEntry{time: uint64(i)}) + } + if q.numEntries != maxOPLQueue { + t.Fatalf("numEntries=%d, want %d", q.numEntries, maxOPLQueue) + } +} + +// TestReleaseVoiceCrash covers the "Doom 2 1.666 OPL crash emulation" branch +// where the release index is out of range. +func TestReleaseVoiceCrash(t *testing.T) { + p := newTestPlayer(t, false) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 60, Param2: 90}) + p.releaseVoice(p.allocedNum + 5) + if p.allocedNum != 0 || p.freeNum != 0 { + t.Fatalf("crash emulation should zero counts, got alloced=%d free=%d", p.allocedNum, p.freeNum) + } +} + +// TestFrequencyForVoiceExtremes covers the note-wrap, octave clamp, negative +// frequency-index clamp, fixed-note and fine-tuning branches of +// frequencyForVoice. +func TestFrequencyForVoiceExtremes(t *testing.T) { + p := newTestPlayer(t, false) + v := &p.voiceStore[0] + v.channel = &p.channels[0] + v.currentInstr = 0 + v.currentInstrVoice = 0 + + // note > 95 wrap and high octave clamp. + v.note = 200 + p.channels[0].bend = 300 + _ = p.frequencyForVoice(v) + + // Second-voice fine-tuning with a negative frequency index (clamped to 0). + v.currentInstrVoice = 1 + v.note = 0 + p.channels[0].bend = -500 + _ = p.frequencyForVoice(v) + + // Fixed-note instrument: the base-note offset is not applied. + fixed := -1 + for i := 0; i < len(p.bank.Instruments); i++ { + if p.bank.Instruments[i].Flags&0x0001 != 0 { + fixed = i + break + } + } + if fixed >= 0 { + v.currentInstr = fixed + v.currentInstrVoice = 0 + v.note = 60 + p.channels[0].bend = 0 + _ = p.frequencyForVoice(v) + } +} + +// TestSetChannelVolumeClip covers the start-volume clip branch. +func TestSetChannelVolumeClip(t *testing.T) { + p := newTestPlayer(t, false) + p.currentMusicVolume = 127 + p.startMusicVolume = 40 + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 60, Param2: 90}) + // Controller volume 100 with clipStart -> clamped to startMusicVolume. + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 0, Param1: 0x07, Param2: 100}) +} + +// TestReplaceDoom1MultiChannel forces ReplaceExistingVoiceDoom1 to pick a +// higher-numbered channel's voice. +func TestReplaceDoom1MultiChannel(t *testing.T) { + p := newTestPlayer(t, false) + p.oplDrvVer = drvDoom1_1_666 + p.opl3mode = false + // Fill voices across ascending channels so the highest channel is stolen. + for ch := 0; ch < 6; ch++ { + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: uint8(ch), Param1: uint8(50 + ch), Param2: 90}) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: uint8(ch), Param1: uint8(60 + ch), Param2: 90}) + } +} + +// TestTrackTimerExhausted covers the !ok early return when the iterator is +// already past the end of the track. +func TestTrackTimerExhausted(t *testing.T) { + p := newTestPlayer(t, false) + it := p.tracks[0].iter + for { + if _, ok := it.Next(); !ok { + break + } + } + p.trackTimerCallback(0) // iterator exhausted -> no-op +} + +// TestRegisterSongBadMUS covers the mus.Convert error branch. +func TestRegisterSongBadMUS(t *testing.T) { + p := newTestPlayer(t, false) + // Valid MUS magic but a truncated/garbage body so Convert fails. + bad := append([]byte("MUS\x1a"), make([]byte, 8)...) + if err := p.RegisterSong(bad); err == nil { + t.Fatal("expected mus.Convert error for malformed MUS") + } +} + +// TestStereoCorrectPan covers the opl_stereo_correct pan-reversal branch. +func TestStereoCorrectPan(t *testing.T) { + p := newTestPlayer(t, true) + p.stereoCorrect = true + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 0, Param1: 60, Param2: 100}) + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 0, Param1: 0x0A, Param2: 100}) + // Same resulting pan again -> the channel.pan == regPan (no-change) branch. + p.processEvent(&midi.Event{Type: midi.Controller, Channel: 0, Param1: 0x0A, Param2: 100}) +} + +// TestReplaceDoom2Exact fills exactly numOplVoices-1 voices with single-voice +// notes, then triggers a double-voice note so the second +// ReplaceExistingVoiceDoom2 call (allocedNum == numOplVoices-1 && double) runs. +func TestReplaceDoom2Exact(t *testing.T) { + p := newTestPlayer(t, true) // OPL3: 18 voices + p.oplDrvVer = drvDoom2_1_666 + + single := findSingleVoiceInstrument(p) + p.processEvent(&midi.Event{Type: midi.ProgramChange, Channel: 3, Param1: uint8(single)}) + // Fill 17 single voices. + for i := 0; i < 17; i++ { + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 3, Param1: uint8(40 + i), Param2: 90}) + } + if p.allocedNum != p.numOplVoices-1 { + t.Fatalf("setup: allocedNum=%d, want %d", p.allocedNum, p.numOplVoices-1) + } + // Now a double-voice note on the same channel. + dv := findDoubleVoiceInstrument(p) + p.processEvent(&midi.Event{Type: midi.ProgramChange, Channel: 3, Param1: uint8(dv)}) + p.processEvent(&midi.Event{Type: midi.NoteOn, Channel: 3, Param1: 80, Param2: 90}) +} + +func findSingleVoiceInstrument(p *Player) int { + for i := 0; i < 128; i++ { + if p.bank.Instruments[i].Flags&0x0004 == 0 { + return i + } + } + return 0 +} diff --git a/music/oplplayer/oplplayer.go b/music/oplplayer/oplplayer.go new file mode 100644 index 0000000..57404de --- /dev/null +++ b/music/oplplayer/oplplayer.go @@ -0,0 +1,967 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom i_oplmusic.c (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +// Package oplplayer is a pure-Go port of chocolate-doom's DMX OPL music +// player (i_oplmusic.c). It ties MIDI events and the GENMIDI instrument bank +// to OPL register writes, driving the Nuked OPL3 emulator to produce PCM. +// +// The chocolate-doom timer/callback model (OPL_SetCallback + the opl_sdl mixing +// clock) is replaced by a synchronous sample-clock render loop in Read: MIDI +// events are processed at their sample-quantized times exactly as the reference +// timer would, so the audio produced is equivalent to chocolate-doom. +package oplplayer + +import ( + "github.com/go-doom/engine/music/genmidi" + "github.com/go-doom/engine/music/midi" + "github.com/go-doom/engine/music/mus" + "github.com/go-doom/engine/music/opl" +) + +// OPL register constants (opl.h). +const ( + numOperators = 21 // OPL_NUM_OPERATORS + numVoices = 9 // OPL_NUM_VOICES + + regWaveformEnable = 0x01 + regTimer1 = 0x02 + regTimer2 = 0x03 + regTimerCtrl = 0x04 + regFMMode = 0x08 + regNew = 0x105 + + regsTremolo = 0x20 + regsLevel = 0x40 + regsAttack = 0x60 + regsSustain = 0x80 + regsWaveform = 0xE0 + + regsFreq1 = 0xA0 + regsFreq2 = 0xB0 + regsFeedback = 0xC0 +) + +// midiChannelsPerTrack mirrors MIDI_CHANNELS_PER_TRACK. +const midiChannelsPerTrack = 16 + +// percussionLogLen mirrors PERCUSSION_LOG_LEN. +const percussionLogLen = 16 + +// driverVer selects which DMX library version's quirks to emulate +// (opl_driver_ver_t). The default is opl_doom_1_9. +type driverVer int + +const ( + drvDoom1_1_666 driverVer = iota // Doom 1 v1.666 + drvDoom2_1_666 // Doom 2 v1.666, Hexen, Heretic + drvDoom1_9 // Doom v1.9, Strife +) + +// channelData is opl_channel_data_t: state of one MIDI channel. +type channelData struct { + instrument int // index into bank.Instruments (main_instrs base = 0) + volume int + volumeBase int + pan int + bend int +} + +// trackData is opl_track_data_t: a playing track's iterator. +type trackData struct { + iter *midi.TrackIterator +} + +// voice is opl_voice_s: a hardware OPL voice. +type voice struct { + index int + op1, op2 int + array int // 0 or 0x100 (second OPL3 register bank) + + currentInstr int // index into bank.Instruments, -1 if none + currentInstrVoice uint + + channel *channelData + + key uint + note uint + freq uint + + noteVolume uint + carVolume uint + modVolume uint + regPan int + priority uint +} + +// traceEntry records a single OPL register write for the differential trace +// oracle. +type traceEntry struct { + us uint64 + reg int + val int +} + +// Player renders OPL music from MIDI data. +type Player struct { + chip *opl.Chip + sampleRate int + bank *genmidi.Bank + + oplDrvVer driverVer + opl3mode bool + numOplVoices int + stereoCorrect bool + + startMusicVolume int + currentMusicVolume int + + voiceStore [numVoices * 2]voice + freeList [numVoices * 2]*voice + allocedList [numVoices * 2]*voice + freeNum int + allocedNum int + + channels [midiChannelsPerTrack]channelData + + file *midi.File + tracks []trackData + numTracks int + runningTracks int + songLooping bool + + ticksPerBeat uint + usPerBeat uint + + lastPerc [percussionLogLen]uint8 + lastPercCount uint + + // scheduler state (replaces the opl_sdl timer clock). + queue *callbackQueue + currentUs uint64 + pauseOffset uint64 + paused bool + playing bool + + // trace, when non-nil, captures every register write (test hook). + trace *[]traceEntry +} + +// New builds a player: it loads the GENMIDI bank, creates an OPL chip at +// sampleRate and runs the OPL register initialisation sequence. opl3 selects +// OPL3 (18-voice) mode. +func New(genmidiLump []byte, sampleRate int, opl3 bool) (*Player, error) { + return newPlayer(genmidiLump, sampleRate, opl3, false) +} + +func newPlayer(genmidiLump []byte, sampleRate int, opl3, traceEnabled bool) (*Player, error) { + bank, err := genmidi.Load(genmidiLump) + if err != nil { + return nil, err + } + + p := &Player{ + chip: opl.NewChip(uint32(sampleRate)), + sampleRate: sampleRate, + bank: bank, + oplDrvVer: drvDoom1_9, + opl3mode: opl3, + currentMusicVolume: 127, + queue: newQueue(), + } + if opl3 { + p.numOplVoices = numVoices * 2 + } else { + p.numOplVoices = numVoices + } + if traceEnabled { + p.trace = &[]traceEntry{} + } + + p.initRegisters(opl3) + p.initVoices() + return p, nil +} + +// isPercussion reports whether the given instrument index is a percussion +// instrument (current_instr >= percussion_instrs in the reference). +func isPercussion(instr int) bool { return instr >= genmidi.NumInstruments } + +// instr returns a pointer to the instrument at the given bank index. +func (p *Player) instr(index int) *genmidi.Instrument { return &p.bank.Instruments[index] } + +// writeRegister writes an OPL register (OPL_WriteRegister). The 0x100 bit +// selects the second OPL3 register bank. Writes are captured for the trace +// oracle when enabled. +func (p *Player) writeRegister(reg, val int) { + if p.trace != nil { + *p.trace = append(*p.trace, traceEntry{us: p.currentUs, reg: reg, val: val}) + } + p.chip.WriteRegBuffered(uint16(reg), uint8(val)) +} + +// initRegisters ports OPL_InitRegisters from opl.c. +func (p *Player) initRegisters(opl3 bool) { + for r := regsLevel; r <= regsLevel+numOperators; r++ { + p.writeRegister(r, 0x3f) + } + for r := regsAttack; r <= regsWaveform+numOperators; r++ { + p.writeRegister(r, 0x00) + } + for r := 1; r < regsLevel; r++ { + p.writeRegister(r, 0x00) + } + + p.writeRegister(regTimerCtrl, 0x60) + p.writeRegister(regTimerCtrl, 0x80) + p.writeRegister(regWaveformEnable, 0x20) + + if opl3 { + p.writeRegister(regNew, 0x01) + for r := regsLevel; r <= regsLevel+numOperators; r++ { + p.writeRegister(r|0x100, 0x3f) + } + for r := regsAttack; r <= regsWaveform+numOperators; r++ { + p.writeRegister(r|0x100, 0x00) + } + for r := 1; r < regsLevel; r++ { + p.writeRegister(r|0x100, 0x00) + } + } + + p.writeRegister(regFMMode, 0x40) + + if opl3 { + p.writeRegister(regNew, 0x01) + } +} + +// initVoices ports InitVoices. +func (p *Player) initVoices() { + p.freeNum = p.numOplVoices + p.allocedNum = 0 + + for i := 0; i < p.numOplVoices; i++ { + v := &p.voiceStore[i] + v.index = i % numVoices + v.op1 = voiceOperators[0][i%numVoices] + v.op2 = voiceOperators[1][i%numVoices] + v.array = (i / numVoices) << 8 + v.currentInstr = -1 + p.freeList[i] = v + } +} + +// getFreeVoice ports GetFreeVoice. +func (p *Player) getFreeVoice() *voice { + if p.freeNum == 0 { + return nil + } + + result := p.freeList[0] + p.freeNum-- + for i := 0; i < p.freeNum; i++ { + p.freeList[i] = p.freeList[i+1] + } + + p.allocedList[p.allocedNum] = result + p.allocedNum++ + return result +} + +// releaseVoice ports ReleaseVoice. +func (p *Player) releaseVoice(index int) { + // Doom 2 1.666 OPL crash emulation. + if index >= p.allocedNum { + p.allocedNum = 0 + p.freeNum = 0 + return + } + + v := p.allocedList[index] + p.voiceKeyOff(v) + + v.channel = nil + v.note = 0 + + doubleVoice := v.currentInstrVoice != 0 + + p.allocedNum-- + for i := index; i < p.allocedNum; i++ { + p.allocedList[i] = p.allocedList[i+1] + } + + p.freeList[p.freeNum] = v + p.freeNum++ + + if doubleVoice && p.oplDrvVer < drvDoom1_9 { + p.releaseVoice(index) + } +} + +// loadOperatorData ports LoadOperatorData. +func (p *Player) loadOperatorData(operator int, data *genmidi.Operator, maxLevel bool, volume *uint) { + level := int(data.Scale) + if maxLevel { + level |= 0x3f + } else { + level |= int(data.Level) + } + + *volume = uint(level) + + p.writeRegister(regsLevel+operator, level) + p.writeRegister(regsTremolo+operator, int(data.TremoloVibrato)) + p.writeRegister(regsAttack+operator, int(data.AttackDecay)) + p.writeRegister(regsSustain+operator, int(data.SustainRelease)) + p.writeRegister(regsWaveform+operator, int(data.Waveform)) +} + +// setVoiceInstrument ports SetVoiceInstrument. +func (p *Player) setVoiceInstrument(v *voice, instr int, instrVoice uint) { + if v.currentInstr == instr && v.currentInstrVoice == instrVoice { + return + } + + v.currentInstr = instr + v.currentInstrVoice = instrVoice + + data := &p.instr(instr).Voices[instrVoice] + + modulating := (data.Feedback & 0x01) == 0 + + p.loadOperatorData(v.op2|v.array, &data.Carrier, true, &v.carVolume) + p.loadOperatorData(v.op1|v.array, &data.Modulator, !modulating, &v.modVolume) + + p.writeRegister((regsFeedback+v.index)|v.array, int(data.Feedback)|v.regPan) + + v.priority = 0x0f - uint(data.Carrier.AttackDecay>>4) + 0x0f - uint(data.Carrier.SustainRelease&0x0f) +} + +// setVoiceVolume ports SetVoiceVolume. +func (p *Player) setVoiceVolume(v *voice, volume uint) { + v.noteVolume = volume + + oplVoice := &p.instr(v.currentInstr).Voices[v.currentInstrVoice] + + midiVolume := 2 * (volumeMappingTable[v.channel.volume] + 1) + fullVolume := (volumeMappingTable[v.noteVolume] * midiVolume) >> 9 + + carVolume := 0x3f - fullVolume + + if carVolume != (v.carVolume & 0x3f) { + v.carVolume = carVolume | (v.carVolume & 0xc0) + + p.writeRegister((regsLevel+v.op2)|v.array, int(v.carVolume)) + + if (oplVoice.Feedback&0x01) != 0 && oplVoice.Modulator.Level != 0x3f { + modVolume := uint(oplVoice.Modulator.Level) + if modVolume < carVolume { + modVolume = carVolume + } + + modVolume |= v.modVolume & 0xc0 + + if modVolume != v.modVolume { + v.modVolume = modVolume + p.writeRegister((regsLevel+v.op1)|v.array, + int(modVolume|uint(oplVoice.Modulator.Scale&0xc0))) + } + } + } +} + +// setVoicePan ports SetVoicePan. +func (p *Player) setVoicePan(v *voice, pan int) { + v.regPan = pan + oplVoice := &p.instr(v.currentInstr).Voices[v.currentInstrVoice] + p.writeRegister((regsFeedback+v.index)|v.array, int(oplVoice.Feedback)|pan) +} + +// setMusicVolume ports I_OPL_SetMusicVolume. +func (p *Player) setMusicVolume(volume int) { + if p.currentMusicVolume == volume { + return + } + + p.currentMusicVolume = volume + + for i := 0; i < midiChannelsPerTrack; i++ { + if i == 15 { + p.setChannelVolume(&p.channels[i], volume, false) + } else { + p.setChannelVolume(&p.channels[i], p.channels[i].volumeBase, false) + } + } +} + +// voiceKeyOff ports VoiceKeyOff. +func (p *Player) voiceKeyOff(v *voice) { + p.writeRegister((regsFreq2+v.index)|v.array, int(v.freq>>8)) +} + +// trackChannelForEvent ports TrackChannelForEvent (the MUS 9<->15 swap). +func (p *Player) trackChannelForEvent(ev *midi.Event) *channelData { + channelNum := int(ev.Channel) + if channelNum == 9 { + channelNum = 15 + } else if channelNum == 15 { + channelNum = 9 + } + return &p.channels[channelNum] +} + +// keyOffEvent ports KeyOffEvent. +func (p *Player) keyOffEvent(ev *midi.Event) { + channel := p.trackChannelForEvent(ev) + key := uint(ev.Param1) + + for i := 0; i < p.allocedNum; i++ { + if p.allocedList[i].channel == channel && p.allocedList[i].key == key { + p.releaseVoice(i) + i-- + } + } +} + +// replaceExistingVoice ports ReplaceExistingVoice (opl_doom_1_9). +func (p *Player) replaceExistingVoice() { + result := 0 + for i := 0; i < p.allocedNum; i++ { + if p.allocedList[i].currentInstrVoice != 0 || + !p.channelLess(p.allocedList[i].channel, p.allocedList[result].channel) { + result = i + } + } + p.releaseVoice(result) +} + +// replaceExistingVoiceDoom1 ports ReplaceExistingVoiceDoom1. +func (p *Player) replaceExistingVoiceDoom1() { + result := 0 + for i := 0; i < p.allocedNum; i++ { + if p.channelLess(p.allocedList[result].channel, p.allocedList[i].channel) { + result = i + } + } + p.releaseVoice(result) +} + +// replaceExistingVoiceDoom2 ports ReplaceExistingVoiceDoom2. +func (p *Player) replaceExistingVoiceDoom2(channel *channelData) { + result := 0 + priority := uint(0x8000) + for i := 0; i < p.allocedNum-3; i++ { + if p.allocedList[i].priority < priority && + !p.channelLess(p.allocedList[i].channel, channel) { + priority = p.allocedList[i].priority + result = i + } + } + p.releaseVoice(result) +} + +// channelLess reports whether channel a sorts before channel b, matching the +// pointer comparisons the reference performs on &channels[i]. +func (p *Player) channelLess(a, b *channelData) bool { + return p.channelIndex(a) < p.channelIndex(b) +} + +func (p *Player) channelIndex(c *channelData) int { + for i := range p.channels { + if &p.channels[i] == c { + return i + } + } + return -1 +} + +// frequencyForVoice ports FrequencyForVoice. +func (p *Player) frequencyForVoice(v *voice) uint { + gmVoice := &p.instr(v.currentInstr).Voices[v.currentInstrVoice] + + note := int(v.note) + + if p.instr(v.currentInstr).Flags&genmidi.FlagFixed == 0 { + note += int(gmVoice.BaseNoteOffset) + } + + for note < 0 { + note += 12 + } + for note > 95 { + note -= 12 + } + + freqIndex := 64 + 32*note + v.channel.bend + + if v.currentInstrVoice != 0 { + freqIndex += int(p.instr(v.currentInstr).FineTuning)/2 - 64 + } + + if freqIndex < 0 { + freqIndex = 0 + } + + if freqIndex < 284 { + return uint(frequencyCurve[freqIndex]) + } + + subIndex := (freqIndex - 284) % (12 * 32) + octave := (freqIndex - 284) / (12 * 32) + + if octave >= 7 { + octave = 7 + } + + return uint(frequencyCurve[subIndex+284]) | (uint(octave) << 10) +} + +// updateVoiceFrequency ports UpdateVoiceFrequency. +func (p *Player) updateVoiceFrequency(v *voice) { + freq := p.frequencyForVoice(v) + + if v.freq != freq { + p.writeRegister((regsFreq1+v.index)|v.array, int(freq&0xff)) + p.writeRegister((regsFreq2+v.index)|v.array, int((freq>>8)|0x20)) + v.freq = freq + } +} + +// voiceKeyOn ports VoiceKeyOn. +func (p *Player) voiceKeyOn(channel *channelData, instrument int, instrumentVoice, note, key, volume uint) { + if !p.opl3mode && p.oplDrvVer == drvDoom1_1_666 { + instrumentVoice = 0 + } + + v := p.getFreeVoice() + if v == nil { + return + } + + v.channel = channel + v.key = key + + if p.instr(instrument).Flags&genmidi.FlagFixed != 0 { + v.note = uint(p.instr(instrument).FixedNote) + } else { + v.note = note + } + + v.regPan = channel.pan + + p.setVoiceInstrument(v, instrument, instrumentVoice) + p.setVoiceVolume(v, volume) + + v.freq = 0 + p.updateVoiceFrequency(v) +} + +// keyOnEvent ports KeyOnEvent. +func (p *Player) keyOnEvent(ev *midi.Event) { + note := uint(ev.Param1) + key := uint(ev.Param1) + volume := uint(ev.Param2) + + if ev.Param2 == 0 { + p.keyOffEvent(ev) + return + } + + channel := p.trackChannelForEvent(ev) + + var instrument int + if ev.Channel == 9 { + if key < 35 || key > 81 { + return + } + instrument = genmidi.NumInstruments + int(key-35) + p.lastPerc[p.lastPercCount] = uint8(key) + p.lastPercCount = (p.lastPercCount + 1) % percussionLogLen + note = 60 + } else { + instrument = channel.instrument + } + + doubleVoice := p.instr(instrument).Flags&genmidi.Flag2Voice != 0 + + switch p.oplDrvVer { + case drvDoom1_1_666: + voicenum := 1 + if doubleVoice { + voicenum = 2 + } + if !p.opl3mode { + voicenum = 1 + } + for p.allocedNum > p.numOplVoices-voicenum { + p.replaceExistingVoiceDoom1() + } + if doubleVoice { + p.voiceKeyOn(channel, instrument, 1, note, key, volume) + } + p.voiceKeyOn(channel, instrument, 0, note, key, volume) + case drvDoom2_1_666: + if p.allocedNum == p.numOplVoices { + p.replaceExistingVoiceDoom2(channel) + } + if p.allocedNum == p.numOplVoices-1 && doubleVoice { + p.replaceExistingVoiceDoom2(channel) + } + if doubleVoice { + p.voiceKeyOn(channel, instrument, 1, note, key, volume) + } + p.voiceKeyOn(channel, instrument, 0, note, key, volume) + default: + if p.freeNum == 0 { + p.replaceExistingVoice() + } + p.voiceKeyOn(channel, instrument, 0, note, key, volume) + if doubleVoice { + p.voiceKeyOn(channel, instrument, 1, note, key, volume) + } + } +} + +// programChangeEvent ports ProgramChangeEvent. +func (p *Player) programChangeEvent(ev *midi.Event) { + channel := p.trackChannelForEvent(ev) + channel.instrument = int(ev.Param1) +} + +// setChannelVolume ports SetChannelVolume. +func (p *Player) setChannelVolume(channel *channelData, volume int, clipStart bool) { + channel.volumeBase = volume + + if volume > p.currentMusicVolume { + volume = p.currentMusicVolume + } + if clipStart && volume > p.startMusicVolume { + volume = p.startMusicVolume + } + + channel.volume = volume + + for i := 0; i < p.numOplVoices; i++ { + if p.voiceStore[i].channel == channel { + p.setVoiceVolume(&p.voiceStore[i], p.voiceStore[i].noteVolume) + } + } +} + +// setChannelPan ports SetChannelPan. +func (p *Player) setChannelPan(channel *channelData, pan int) { + if p.stereoCorrect { + pan = 144 - pan + } + + if p.opl3mode { + var regPan int + if pan >= 96 { + regPan = 0x10 + } else if pan <= 48 { + regPan = 0x20 + } else { + regPan = 0x30 + } + if channel.pan != regPan { + channel.pan = regPan + for i := 0; i < p.numOplVoices; i++ { + if p.voiceStore[i].channel == channel { + p.setVoicePan(&p.voiceStore[i], regPan) + } + } + } + } +} + +// allNotesOff ports AllNotesOff. +func (p *Player) allNotesOff(channel *channelData) { + for i := 0; i < p.allocedNum; i++ { + if p.allocedList[i].channel == channel { + p.releaseVoice(i) + i-- + } + } +} + +// controllerEvent ports ControllerEvent. +func (p *Player) controllerEvent(ev *midi.Event) { + channel := p.trackChannelForEvent(ev) + controller := ev.Param1 + param := int(ev.Param2) + + switch controller { + case 0x07: // MIDI_CONTROLLER_VOLUME_MSB + p.setChannelVolume(channel, param, true) + case 0x0A: // MIDI_CONTROLLER_PAN + p.setChannelPan(channel, param) + case 0x7B: // MIDI_CONTROLLER_ALL_NOTES_OFF + p.allNotesOff(channel) + } +} + +// pitchBendEvent ports PitchBendEvent. +func (p *Player) pitchBendEvent(ev *midi.Event) { + channel := p.trackChannelForEvent(ev) + channel.bend = int(ev.Param2) - 64 + + var updated [numVoices * 2]*voice + var notUpdated [numVoices * 2]*voice + updatedNum := 0 + notUpdatedNum := 0 + + for i := 0; i < p.allocedNum; i++ { + if p.allocedList[i].channel == channel { + p.updateVoiceFrequency(p.allocedList[i]) + updated[updatedNum] = p.allocedList[i] + updatedNum++ + } else { + notUpdated[notUpdatedNum] = p.allocedList[i] + notUpdatedNum++ + } + } + + for i := 0; i < notUpdatedNum; i++ { + p.allocedList[i] = notUpdated[i] + } + for i := 0; i < updatedNum; i++ { + p.allocedList[i+notUpdatedNum] = updated[i] + } +} + +// metaSetTempo ports MetaSetTempo. +func (p *Player) metaSetTempo(tempo uint) { + p.queue.adjustCallbacks(p.currentUs, float32(p.usPerBeat)/float32(tempo)) + p.usPerBeat = tempo +} + +// metaEvent ports MetaEvent. +func (p *Player) metaEvent(ev *midi.Event) { + if ev.MetaType == midi.MetaSetTempo { + if len(ev.Data) == 3 { + p.metaSetTempo(uint(ev.Data[0])<<16 | uint(ev.Data[1])<<8 | uint(ev.Data[2])) + } + } +} + +// processEvent ports ProcessEvent. +func (p *Player) processEvent(ev *midi.Event) { + switch ev.Type { + case midi.NoteOff: + p.keyOffEvent(ev) + case midi.NoteOn: + p.keyOnEvent(ev) + case midi.Controller: + p.controllerEvent(ev) + case midi.ProgramChange: + p.programChangeEvent(ev) + case midi.PitchBend: + p.pitchBendEvent(ev) + case midi.Meta: + p.metaEvent(ev) + } +} + +// setCallback ports OPL_SetCallback for the synchronous scheduler. +func (p *Player) setCallback(us uint64, e queueEntry) { + e.time = p.currentUs - p.pauseOffset + us + p.queue.push(e) +} + +// restartSong ports RestartSong. +func (p *Player) restartSong() { + p.runningTracks = p.numTracks + p.startMusicVolume = p.currentMusicVolume + + for i := 0; i < p.numTracks; i++ { + p.tracks[i].iter.Restart() + p.scheduleTrack(i) + } + for i := 0; i < midiChannelsPerTrack; i++ { + p.initChannel(&p.channels[i]) + } +} + +// trackTimerCallback ports TrackTimerCallback. +func (p *Player) trackTimerCallback(trackIdx int) { + ev, ok := p.tracks[trackIdx].iter.Next() + if !ok { + return + } + + p.processEvent(ev) + + if ev.Type == midi.Meta && ev.MetaType == midi.MetaEndOfTrack { + p.runningTracks-- + if p.runningTracks <= 0 && p.songLooping { + p.setCallback(5000, queueEntry{kind: cbRestartSong}) + } + return + } + + p.scheduleTrack(trackIdx) +} + +// scheduleTrack ports ScheduleTrack. +func (p *Player) scheduleTrack(trackIdx int) { + nticks := uint64(p.tracks[trackIdx].iter.DeltaTime()) + us := (nticks * uint64(p.usPerBeat)) / uint64(p.ticksPerBeat) + p.setCallback(us, queueEntry{kind: cbTrackTimer, track: trackIdx}) +} + +// initChannel ports InitChannel. +func (p *Player) initChannel(channel *channelData) { + channel.instrument = 0 + channel.volume = p.currentMusicVolume + channel.volumeBase = 100 + if channel.volume > channel.volumeBase { + channel.volume = channel.volumeBase + } + channel.pan = 0x30 + channel.bend = 0 +} + +// invoke dispatches a scheduled callback. +func (p *Player) invoke(e queueEntry) { + switch e.kind { + case cbTrackTimer: + p.trackTimerCallback(e.track) + case cbRestartSong: + p.restartSong() + } +} + +// RegisterSong parses MIDI (or MUS, converted on the fly) data ready for +// playback. It mirrors I_OPL_RegisterSong. +func (p *Player) RegisterSong(midiData []byte) error { + data := midiData + if mus.IsMUS(data) { + conv, err := mus.Convert(data) + if err != nil { + return err + } + data = conv + } + + f, err := midi.Parse(data) + if err != nil { + return err + } + p.file = f + return nil +} + +// Play starts playback of the registered song (I_OPL_PlaySong). +func (p *Player) Play(looping bool) { + if p.file == nil { + return + } + + p.numTracks = p.file.NumTracks() + p.tracks = make([]trackData, p.numTracks) + p.runningTracks = p.numTracks + p.songLooping = looping + + p.ticksPerBeat = uint(p.file.TimeDivisionTicks()) + p.usPerBeat = 500 * 1000 + + p.startMusicVolume = p.currentMusicVolume + + for i := 0; i < p.numTracks; i++ { + p.tracks[i].iter = p.file.IterateTrack(i) + p.scheduleTrack(i) + } + for i := 0; i < midiChannelsPerTrack; i++ { + p.initChannel(&p.channels[i]) + } + + p.paused = false + p.playing = true +} + +// Stop stops playback and frees all voices (I_OPL_StopSong). +func (p *Player) Stop() { + p.queue.clear() + + for i := 0; i < midiChannelsPerTrack; i++ { + p.allNotesOff(&p.channels[i]) + } + + p.tracks = nil + p.numTracks = 0 + p.playing = false +} + +// Pause pauses playback (I_OPL_PauseSong). +func (p *Player) Pause() { + p.paused = true + + for i := 0; i < p.numOplVoices; i++ { + if p.voiceStore[i].channel != nil && !isPercussion(p.voiceStore[i].currentInstr) { + p.voiceKeyOff(&p.voiceStore[i]) + } + } +} + +// Resume resumes playback (I_OPL_ResumeSong). +func (p *Player) Resume() { p.paused = false } + +// SetVolume sets the music volume (0..127) (I_OPL_SetMusicVolume). +func (p *Player) SetVolume(v int) { p.setMusicVolume(v) } + +// IsPlaying reports whether a song is loaded and playing (I_OPL_MusicIsPlaying). +func (p *Player) IsPlaying() bool { return p.numTracks > 0 } + +// advanceTime advances the sample clock by nsamples, firing any callbacks that +// are now due (opl_sdl.c AdvanceTime). +func (p *Player) advanceTime(nsamples int) { + us := uint64(nsamples) * 1_000_000 / uint64(p.sampleRate) + p.currentUs += us + + if p.paused { + p.pauseOffset += us + } + + for !p.queue.isEmpty() && p.currentUs >= p.queue.peek()+p.pauseOffset { + e := p.queue.pop() + p.invoke(e) + } +} + +// Read renders interleaved stereo int16 PCM into buf (len must be even), +// advancing the internal sample clock and processing MIDI events at their +// sample-quantized times exactly as the chocolate-doom timer would. It returns +// the number of stereo frames written (len(buf)/2). It mirrors the opl_sdl +// mixing callback (OPL_Mix_Callback). +func (p *Player) Read(buf []int16) int { + frames := len(buf) / 2 + produced := 0 + + for produced < frames { + var nsamples int + if p.paused || p.queue.isEmpty() { + nsamples = frames - produced + } else { + nextCallbackTime := p.queue.peek() + p.pauseOffset + var d uint64 + if nextCallbackTime > p.currentUs { + d = nextCallbackTime - p.currentUs + } + ns := (d*uint64(p.sampleRate) + 1_000_000 - 1) / 1_000_000 + nsamples = int(ns) + if nsamples > frames-produced { + nsamples = frames - produced + } + } + + if nsamples > 0 { + p.chip.GenerateStream(buf[produced*2:(produced+nsamples)*2], nsamples) + produced += nsamples + } + + p.advanceTime(nsamples) + } + + return produced +} diff --git a/music/oplplayer/oplplayer_test.go b/music/oplplayer/oplplayer_test.go new file mode 100644 index 0000000..b780f12 --- /dev/null +++ b/music/oplplayer/oplplayer_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom i_oplmusic.c (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +package oplplayer + +import ( + "os" + "strings" + "testing" +) + +const ( + genmidiPath = "../genmidi/testdata/GENMIDI.lmp" + dintroPath = "../midi/testdata/D_INTRO.lmp" + goldenTrace = "testdata/regtrace_dintro.txt" + + traceSampleRate = 44100 + traceBudgetUs = 3_000_000 +) + +func readFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return b +} + +// runTrace drives the scheduler in the simplified event-driven mode used by the +// C oracle harness: current_us jumps directly to each callback's scheduled +// time. This mirrors OPL_RunTrace in scratchpad/opltrace/oplstub.c. +func (p *Player) runTrace(budgetUs uint64) { + for !p.queue.isEmpty() { + t := p.queue.peek() + if t >= budgetUs { + break + } + p.currentUs = t + e := p.queue.pop() + p.invoke(e) + } +} + +// formatTrace renders the captured register-write trace in the same +// "T R V" line format the C harness emits. +func formatTrace(entries []traceEntry) string { + var sb strings.Builder + for _, e := range entries { + sb.WriteByte('T') + sb.WriteString(itoa(int(e.us))) + sb.WriteString(" R") + sb.WriteString(itoa(e.reg)) + sb.WriteString(" V") + sb.WriteString(itoa(e.val)) + sb.WriteByte('\n') + } + return sb.String() +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// TestRegisterTraceOracle is the differential acceptance gate. It runs the Go +// player in the same synchronous, microsecond-driven scheduling mode as the C +// oracle harness (scratchpad/opltrace) and asserts the captured (us, reg, val) +// register-write trace is byte-identical to the golden trace produced by +// chocolate-doom's i_oplmusic.c for the same D_INTRO + GENMIDI + config +// (OPL2 mode, opl_doom_1_9, stereo-correct off, music volume 127). +func TestRegisterTraceOracle(t *testing.T) { + p, err := newPlayer(readFile(t, genmidiPath), traceSampleRate, false, true) + if err != nil { + t.Fatalf("newPlayer: %v", err) + } + if err := p.RegisterSong(readFile(t, dintroPath)); err != nil { + t.Fatalf("RegisterSong: %v", err) + } + + p.Play(true) // looping, to exercise RestartSong within the budget + p.runTrace(traceBudgetUs) + + got := formatTrace(*p.trace) + want := string(readFile(t, goldenTrace)) + + if got == want { + return + } + + // Report the first differing line to make failures actionable. + gl := strings.Split(got, "\n") + wl := strings.Split(want, "\n") + n := len(gl) + if len(wl) < n { + n = len(wl) + } + for i := 0; i < n; i++ { + if gl[i] != wl[i] { + t.Fatalf("register trace mismatch at line %d:\n go: %q\n c: %q\n(go lines=%d, c lines=%d)", + i+1, gl[i], wl[i], len(gl), len(wl)) + } + } + t.Fatalf("register trace length mismatch: go lines=%d, c lines=%d", len(gl), len(wl)) +} + +// TestPCMSmoke renders one second of audio through the public API and asserts +// the output is non-silent and deterministic across two identical runs. +func TestPCMSmoke(t *testing.T) { + const rate = 44100 + + render := func() []int16 { + p, err := New(readFile(t, genmidiPath), rate, false) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := p.RegisterSong(readFile(t, dintroPath)); err != nil { + t.Fatalf("RegisterSong: %v", err) + } + p.SetVolume(127) + p.Play(true) + if !p.IsPlaying() { + t.Fatal("IsPlaying returned false after Play") + } + buf := make([]int16, rate*2) // 1 second, stereo + frames := p.Read(buf) + if frames != rate { + t.Fatalf("Read returned %d frames, want %d", frames, rate) + } + return buf + } + + a := render() + + nonzero := 0 + for _, s := range a { + if s != 0 { + nonzero++ + } + } + if nonzero == 0 { + t.Fatal("rendered audio is entirely silent") + } + + b := render() + for i := range a { + if a[i] != b[i] { + t.Fatalf("audio not deterministic: sample %d differs (%d vs %d)", i, a[i], b[i]) + } + } +} diff --git a/music/oplplayer/queue.go b/music/oplplayer/queue.go new file mode 100644 index 0000000..04565c3 --- /dev/null +++ b/music/oplplayer/queue.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom i_oplmusic.c (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +package oplplayer + +// callback kinds queued in the scheduler. +const ( + cbTrackTimer = iota + cbRestartSong +) + +// maxOPLQueue mirrors MAX_OPL_QUEUE in opl_queue.c. +const maxOPLQueue = 64 + +// queueEntry is one scheduled callback. +type queueEntry struct { + kind int + track int + time uint64 +} + +// callbackQueue is a binary min-heap of pending callbacks, a faithful port of +// opl_queue.c so that callback ordering (including ties) matches the reference +// exactly. +type callbackQueue struct { + entries [maxOPLQueue]queueEntry + numEntries int +} + +func newQueue() *callbackQueue { return &callbackQueue{} } + +func (q *callbackQueue) isEmpty() bool { return q.numEntries == 0 } + +func (q *callbackQueue) clear() { q.numEntries = 0 } + +// push inserts a callback at the given absolute time (OPL_Queue_Push). +func (q *callbackQueue) push(e queueEntry) { + if q.numEntries >= maxOPLQueue { + // OPL_Queue_Push drops on overflow. + return + } + + entryID := q.numEntries + q.numEntries++ + + for entryID > 0 { + parentID := (entryID - 1) / 2 + if e.time >= q.entries[parentID].time { + break + } + q.entries[entryID] = q.entries[parentID] + entryID = parentID + } + + q.entries[entryID] = e +} + +// pop removes and returns the earliest callback (OPL_Queue_Pop). It must not be +// called on an empty queue. +func (q *callbackQueue) pop() queueEntry { + result := q.entries[0] + + q.numEntries-- + entry := q.entries[q.numEntries] + + i := 0 + for { + child1 := i*2 + 1 + child2 := i*2 + 2 + + var nextI int + if child1 < q.numEntries && q.entries[child1].time < entry.time { + if child2 < q.numEntries && q.entries[child2].time < q.entries[child1].time { + nextI = child2 + } else { + nextI = child1 + } + } else if child2 < q.numEntries && q.entries[child2].time < entry.time { + nextI = child2 + } else { + break + } + + q.entries[i] = q.entries[nextI] + i = nextI + } + + q.entries[i] = entry + return result +} + +// peek returns the time of the earliest callback (OPL_Queue_Peek). +func (q *callbackQueue) peek() uint64 { + if q.numEntries > 0 { + return q.entries[0].time + } + return 0 +} + +// adjustCallbacks rescales all queued callback times relative to now by the +// given factor (OPL_Queue_AdjustCallbacks). The float32 arithmetic matches the +// reference C exactly. +func (q *callbackQueue) adjustCallbacks(now uint64, factor float32) { + for i := 0; i < q.numEntries; i++ { + offset := int64(q.entries[i].time - now) + q.entries[i].time = now + uint64(float32(offset)/factor) + } +} diff --git a/music/oplplayer/tables.go b/music/oplplayer/tables.go new file mode 100644 index 0000000..b506887 --- /dev/null +++ b/music/oplplayer/tables.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Go port of chocolate-doom i_oplmusic.c (Simon Howard et al.). +// Ported for the go-doom/engine authors. + +package oplplayer + +// voiceOperators holds the operator numbers used by each of the nine OPL +// voices (voice_operators[2][OPL_NUM_VOICES] in i_oplmusic.c). +var voiceOperators = [2][numVoices]int{ + {0x00, 0x01, 0x02, 0x08, 0x09, 0x0a, 0x10, 0x11, 0x12}, + {0x03, 0x04, 0x05, 0x0b, 0x0c, 0x0d, 0x13, 0x14, 0x15}, +} + +// frequencyCurve maps a frequency index to an OPL F-number (frequency_curve[] +// in i_oplmusic.c). The final value is a deliberate buffer over-run preserved +// from the original DMX table. +var frequencyCurve = [...]uint16{ + 0x133, 0x133, 0x134, 0x134, 0x135, 0x136, 0x136, 0x137, // -1 + 0x137, 0x138, 0x138, 0x139, 0x139, 0x13a, 0x13b, 0x13b, + 0x13c, 0x13c, 0x13d, 0x13d, 0x13e, 0x13f, 0x13f, 0x140, + 0x140, 0x141, 0x142, 0x142, 0x143, 0x143, 0x144, 0x144, + + 0x145, 0x146, 0x146, 0x147, 0x147, 0x148, 0x149, 0x149, // -2 + 0x14a, 0x14a, 0x14b, 0x14c, 0x14c, 0x14d, 0x14d, 0x14e, + 0x14f, 0x14f, 0x150, 0x150, 0x151, 0x152, 0x152, 0x153, + 0x153, 0x154, 0x155, 0x155, 0x156, 0x157, 0x157, 0x158, + + 0x158, 0x159, 0x15a, 0x15a, 0x15b, 0x15b, 0x15c, 0x15d, // 0 + 0x15d, 0x15e, 0x15f, 0x15f, 0x160, 0x161, 0x161, 0x162, + 0x162, 0x163, 0x164, 0x164, 0x165, 0x166, 0x166, 0x167, + 0x168, 0x168, 0x169, 0x16a, 0x16a, 0x16b, 0x16c, 0x16c, + + 0x16d, 0x16e, 0x16e, 0x16f, 0x170, 0x170, 0x171, 0x172, // 1 + 0x172, 0x173, 0x174, 0x174, 0x175, 0x176, 0x176, 0x177, + 0x178, 0x178, 0x179, 0x17a, 0x17a, 0x17b, 0x17c, 0x17c, + 0x17d, 0x17e, 0x17e, 0x17f, 0x180, 0x181, 0x181, 0x182, + + 0x183, 0x183, 0x184, 0x185, 0x185, 0x186, 0x187, 0x188, // 2 + 0x188, 0x189, 0x18a, 0x18a, 0x18b, 0x18c, 0x18d, 0x18d, + 0x18e, 0x18f, 0x18f, 0x190, 0x191, 0x192, 0x192, 0x193, + 0x194, 0x194, 0x195, 0x196, 0x197, 0x197, 0x198, 0x199, + + 0x19a, 0x19a, 0x19b, 0x19c, 0x19d, 0x19d, 0x19e, 0x19f, // 3 + 0x1a0, 0x1a0, 0x1a1, 0x1a2, 0x1a3, 0x1a3, 0x1a4, 0x1a5, + 0x1a6, 0x1a6, 0x1a7, 0x1a8, 0x1a9, 0x1a9, 0x1aa, 0x1ab, + 0x1ac, 0x1ad, 0x1ad, 0x1ae, 0x1af, 0x1b0, 0x1b0, 0x1b1, + + 0x1b2, 0x1b3, 0x1b4, 0x1b4, 0x1b5, 0x1b6, 0x1b7, 0x1b8, // 4 + 0x1b8, 0x1b9, 0x1ba, 0x1bb, 0x1bc, 0x1bc, 0x1bd, 0x1be, + 0x1bf, 0x1c0, 0x1c0, 0x1c1, 0x1c2, 0x1c3, 0x1c4, 0x1c4, + 0x1c5, 0x1c6, 0x1c7, 0x1c8, 0x1c9, 0x1c9, 0x1ca, 0x1cb, + + 0x1cc, 0x1cd, 0x1ce, 0x1ce, 0x1cf, 0x1d0, 0x1d1, 0x1d2, // 5 + 0x1d3, 0x1d3, 0x1d4, 0x1d5, 0x1d6, 0x1d7, 0x1d8, 0x1d8, + 0x1d9, 0x1da, 0x1db, 0x1dc, 0x1dd, 0x1de, 0x1de, 0x1df, + 0x1e0, 0x1e1, 0x1e2, 0x1e3, 0x1e4, 0x1e5, 0x1e5, 0x1e6, + + 0x1e7, 0x1e8, 0x1e9, 0x1ea, 0x1eb, 0x1ec, 0x1ed, 0x1ed, // 6 + 0x1ee, 0x1ef, 0x1f0, 0x1f1, 0x1f2, 0x1f3, 0x1f4, 0x1f5, + 0x1f6, 0x1f6, 0x1f7, 0x1f8, 0x1f9, 0x1fa, 0x1fb, 0x1fc, + 0x1fd, 0x1fe, 0x1ff, 0x200, 0x201, 0x201, 0x202, 0x203, + + 0x204, 0x205, 0x206, 0x207, 0x208, 0x209, 0x20a, 0x20b, // 7 + 0x20c, 0x20d, 0x20e, 0x20f, 0x210, 0x210, 0x211, 0x212, + 0x213, 0x214, 0x215, 0x216, 0x217, 0x218, 0x219, 0x21a, + 0x21b, 0x21c, 0x21d, 0x21e, 0x21f, 0x220, 0x221, 0x222, + + 0x223, 0x224, 0x225, 0x226, 0x227, 0x228, 0x229, 0x22a, // 8 + 0x22b, 0x22c, 0x22d, 0x22e, 0x22f, 0x230, 0x231, 0x232, + 0x233, 0x234, 0x235, 0x236, 0x237, 0x238, 0x239, 0x23a, + 0x23b, 0x23c, 0x23d, 0x23e, 0x23f, 0x240, 0x241, 0x242, + + 0x244, 0x245, 0x246, 0x247, 0x248, 0x249, 0x24a, 0x24b, // 9 + 0x24c, 0x24d, 0x24e, 0x24f, 0x250, 0x251, 0x252, 0x253, + 0x254, 0x256, 0x257, 0x258, 0x259, 0x25a, 0x25b, 0x25c, + 0x25d, 0x25e, 0x25f, 0x260, 0x262, 0x263, 0x264, 0x265, + + 0x266, 0x267, 0x268, 0x269, 0x26a, 0x26c, 0x26d, 0x26e, // 10 + 0x26f, 0x270, 0x271, 0x272, 0x273, 0x275, 0x276, 0x277, + 0x278, 0x279, 0x27a, 0x27b, 0x27d, 0x27e, 0x27f, 0x280, + 0x281, 0x282, 0x284, 0x285, 0x286, 0x287, 0x288, 0x289, + + 0x28b, 0x28c, 0x28d, 0x28e, 0x28f, 0x290, 0x292, 0x293, // 11 + 0x294, 0x295, 0x296, 0x298, 0x299, 0x29a, 0x29b, 0x29c, + 0x29e, 0x29f, 0x2a0, 0x2a1, 0x2a2, 0x2a4, 0x2a5, 0x2a6, + 0x2a7, 0x2a9, 0x2aa, 0x2ab, 0x2ac, 0x2ae, 0x2af, 0x2b0, + + 0x2b1, 0x2b2, 0x2b4, 0x2b5, 0x2b6, 0x2b7, 0x2b9, 0x2ba, // 12 + 0x2bb, 0x2bd, 0x2be, 0x2bf, 0x2c0, 0x2c2, 0x2c3, 0x2c4, + 0x2c5, 0x2c7, 0x2c8, 0x2c9, 0x2cb, 0x2cc, 0x2cd, 0x2ce, + 0x2d0, 0x2d1, 0x2d2, 0x2d4, 0x2d5, 0x2d6, 0x2d8, 0x2d9, + + 0x2da, 0x2dc, 0x2dd, 0x2de, 0x2e0, 0x2e1, 0x2e2, 0x2e4, // 13 + 0x2e5, 0x2e6, 0x2e8, 0x2e9, 0x2ea, 0x2ec, 0x2ed, 0x2ee, + 0x2f0, 0x2f1, 0x2f2, 0x2f4, 0x2f5, 0x2f6, 0x2f8, 0x2f9, + 0x2fb, 0x2fc, 0x2fd, 0x2ff, 0x300, 0x302, 0x303, 0x304, + + 0x306, 0x307, 0x309, 0x30a, 0x30b, 0x30d, 0x30e, 0x310, // 14 + 0x311, 0x312, 0x314, 0x315, 0x317, 0x318, 0x31a, 0x31b, + 0x31c, 0x31e, 0x31f, 0x321, 0x322, 0x324, 0x325, 0x327, + 0x328, 0x329, 0x32b, 0x32c, 0x32e, 0x32f, 0x331, 0x332, + + 0x334, 0x335, 0x337, 0x338, 0x33a, 0x33b, 0x33d, 0x33e, // 15 + 0x340, 0x341, 0x343, 0x344, 0x346, 0x347, 0x349, 0x34a, + 0x34c, 0x34d, 0x34f, 0x350, 0x352, 0x353, 0x355, 0x357, + 0x358, 0x35a, 0x35b, 0x35d, 0x35e, 0x360, 0x361, 0x363, + + 0x365, 0x366, 0x368, 0x369, 0x36b, 0x36c, 0x36e, 0x370, // 16 + 0x371, 0x373, 0x374, 0x376, 0x378, 0x379, 0x37b, 0x37c, + 0x37e, 0x380, 0x381, 0x383, 0x384, 0x386, 0x388, 0x389, + 0x38b, 0x38d, 0x38e, 0x390, 0x392, 0x393, 0x395, 0x397, + + 0x398, 0x39a, 0x39c, 0x39d, 0x39f, 0x3a1, 0x3a2, 0x3a4, // 17 + 0x3a6, 0x3a7, 0x3a9, 0x3ab, 0x3ac, 0x3ae, 0x3b0, 0x3b1, + 0x3b3, 0x3b5, 0x3b7, 0x3b8, 0x3ba, 0x3bc, 0x3bd, 0x3bf, + 0x3c1, 0x3c3, 0x3c4, 0x3c6, 0x3c8, 0x3ca, 0x3cb, 0x3cd, + + 0x3cf, 0x3d1, 0x3d2, 0x3d4, 0x3d6, 0x3d8, 0x3da, 0x3db, // 18 + 0x3dd, 0x3df, 0x3e1, 0x3e3, 0x3e4, 0x3e6, 0x3e8, 0x3ea, + 0x3ec, 0x3ed, 0x3ef, 0x3f1, 0x3f3, 0x3f5, 0x3f6, 0x3f8, + 0x3fa, 0x3fc, 0x3fe, 0x36c, +} + +// volumeMappingTable maps a MIDI volume level to an OPL level value +// (volume_mapping_table[] in i_oplmusic.c). +var volumeMappingTable = [...]uint{ + 0, 1, 3, 5, 6, 8, 10, 11, + 13, 14, 16, 17, 19, 20, 22, 23, + 25, 26, 27, 29, 30, 32, 33, 34, + 36, 37, 39, 41, 43, 45, 47, 49, + 50, 52, 54, 55, 57, 59, 60, 61, + 63, 64, 66, 67, 68, 69, 71, 72, + 73, 74, 75, 76, 77, 79, 80, 81, + 82, 83, 84, 84, 85, 86, 87, 88, + 89, 90, 91, 92, 92, 93, 94, 95, + 96, 96, 97, 98, 99, 99, 100, 101, + 101, 102, 103, 103, 104, 105, 105, 106, + 107, 107, 108, 109, 109, 110, 110, 111, + 112, 112, 113, 113, 114, 114, 115, 115, + 116, 117, 117, 118, 118, 119, 119, 120, + 120, 121, 121, 122, 122, 123, 123, 123, + 124, 124, 125, 125, 126, 126, 127, 127, +} diff --git a/music/oplplayer/testdata/regtrace_dintro.txt b/music/oplplayer/testdata/regtrace_dintro.txt new file mode 100644 index 0000000..bb39df8 --- /dev/null +++ b/music/oplplayer/testdata/regtrace_dintro.txt @@ -0,0 +1,1648 @@ +T0 R64 V63 +T0 R65 V63 +T0 R66 V63 +T0 R67 V63 +T0 R68 V63 +T0 R69 V63 +T0 R70 V63 +T0 R71 V63 +T0 R72 V63 +T0 R73 V63 +T0 R74 V63 +T0 R75 V63 +T0 R76 V63 +T0 R77 V63 +T0 R78 V63 +T0 R79 V63 +T0 R80 V63 +T0 R81 V63 +T0 R82 V63 +T0 R83 V63 +T0 R84 V63 +T0 R85 V63 +T0 R96 V0 +T0 R97 V0 +T0 R98 V0 +T0 R99 V0 +T0 R100 V0 +T0 R101 V0 +T0 R102 V0 +T0 R103 V0 +T0 R104 V0 +T0 R105 V0 +T0 R106 V0 +T0 R107 V0 +T0 R108 V0 +T0 R109 V0 +T0 R110 V0 +T0 R111 V0 +T0 R112 V0 +T0 R113 V0 +T0 R114 V0 +T0 R115 V0 +T0 R116 V0 +T0 R117 V0 +T0 R118 V0 +T0 R119 V0 +T0 R120 V0 +T0 R121 V0 +T0 R122 V0 +T0 R123 V0 +T0 R124 V0 +T0 R125 V0 +T0 R126 V0 +T0 R127 V0 +T0 R128 V0 +T0 R129 V0 +T0 R130 V0 +T0 R131 V0 +T0 R132 V0 +T0 R133 V0 +T0 R134 V0 +T0 R135 V0 +T0 R136 V0 +T0 R137 V0 +T0 R138 V0 +T0 R139 V0 +T0 R140 V0 +T0 R141 V0 +T0 R142 V0 +T0 R143 V0 +T0 R144 V0 +T0 R145 V0 +T0 R146 V0 +T0 R147 V0 +T0 R148 V0 +T0 R149 V0 +T0 R150 V0 +T0 R151 V0 +T0 R152 V0 +T0 R153 V0 +T0 R154 V0 +T0 R155 V0 +T0 R156 V0 +T0 R157 V0 +T0 R158 V0 +T0 R159 V0 +T0 R160 V0 +T0 R161 V0 +T0 R162 V0 +T0 R163 V0 +T0 R164 V0 +T0 R165 V0 +T0 R166 V0 +T0 R167 V0 +T0 R168 V0 +T0 R169 V0 +T0 R170 V0 +T0 R171 V0 +T0 R172 V0 +T0 R173 V0 +T0 R174 V0 +T0 R175 V0 +T0 R176 V0 +T0 R177 V0 +T0 R178 V0 +T0 R179 V0 +T0 R180 V0 +T0 R181 V0 +T0 R182 V0 +T0 R183 V0 +T0 R184 V0 +T0 R185 V0 +T0 R186 V0 +T0 R187 V0 +T0 R188 V0 +T0 R189 V0 +T0 R190 V0 +T0 R191 V0 +T0 R192 V0 +T0 R193 V0 +T0 R194 V0 +T0 R195 V0 +T0 R196 V0 +T0 R197 V0 +T0 R198 V0 +T0 R199 V0 +T0 R200 V0 +T0 R201 V0 +T0 R202 V0 +T0 R203 V0 +T0 R204 V0 +T0 R205 V0 +T0 R206 V0 +T0 R207 V0 +T0 R208 V0 +T0 R209 V0 +T0 R210 V0 +T0 R211 V0 +T0 R212 V0 +T0 R213 V0 +T0 R214 V0 +T0 R215 V0 +T0 R216 V0 +T0 R217 V0 +T0 R218 V0 +T0 R219 V0 +T0 R220 V0 +T0 R221 V0 +T0 R222 V0 +T0 R223 V0 +T0 R224 V0 +T0 R225 V0 +T0 R226 V0 +T0 R227 V0 +T0 R228 V0 +T0 R229 V0 +T0 R230 V0 +T0 R231 V0 +T0 R232 V0 +T0 R233 V0 +T0 R234 V0 +T0 R235 V0 +T0 R236 V0 +T0 R237 V0 +T0 R238 V0 +T0 R239 V0 +T0 R240 V0 +T0 R241 V0 +T0 R242 V0 +T0 R243 V0 +T0 R244 V0 +T0 R245 V0 +T0 R1 V0 +T0 R2 V0 +T0 R3 V0 +T0 R4 V0 +T0 R5 V0 +T0 R6 V0 +T0 R7 V0 +T0 R8 V0 +T0 R9 V0 +T0 R10 V0 +T0 R11 V0 +T0 R12 V0 +T0 R13 V0 +T0 R14 V0 +T0 R15 V0 +T0 R16 V0 +T0 R17 V0 +T0 R18 V0 +T0 R19 V0 +T0 R20 V0 +T0 R21 V0 +T0 R22 V0 +T0 R23 V0 +T0 R24 V0 +T0 R25 V0 +T0 R26 V0 +T0 R27 V0 +T0 R28 V0 +T0 R29 V0 +T0 R30 V0 +T0 R31 V0 +T0 R32 V0 +T0 R33 V0 +T0 R34 V0 +T0 R35 V0 +T0 R36 V0 +T0 R37 V0 +T0 R38 V0 +T0 R39 V0 +T0 R40 V0 +T0 R41 V0 +T0 R42 V0 +T0 R43 V0 +T0 R44 V0 +T0 R45 V0 +T0 R46 V0 +T0 R47 V0 +T0 R48 V0 +T0 R49 V0 +T0 R50 V0 +T0 R51 V0 +T0 R52 V0 +T0 R53 V0 +T0 R54 V0 +T0 R55 V0 +T0 R56 V0 +T0 R57 V0 +T0 R58 V0 +T0 R59 V0 +T0 R60 V0 +T0 R61 V0 +T0 R62 V0 +T0 R63 V0 +T0 R4 V96 +T0 R4 V128 +T0 R1 V32 +T0 R8 V64 +T0 R67 V63 +T0 R35 V1 +T0 R99 V242 +T0 R131 V84 +T0 R227 V3 +T0 R64 V0 +T0 R32 V8 +T0 R96 V241 +T0 R128 V146 +T0 R224 V2 +T0 R192 V56 +T0 R67 V14 +T0 R160 V218 +T0 R176 V50 +T0 R68 V63 +T0 R36 V1 +T0 R100 V242 +T0 R132 V84 +T0 R228 V3 +T0 R65 V0 +T0 R33 V8 +T0 R97 V241 +T0 R129 V146 +T0 R225 V2 +T0 R193 V56 +T0 R68 V14 +T0 R161 V222 +T0 R177 V50 +T0 R69 V63 +T0 R37 V0 +T0 R101 V247 +T0 R133 V151 +T0 R229 V0 +T0 R66 V1 +T0 R34 V0 +T0 R98 V201 +T0 R130 V25 +T0 R226 V0 +T0 R194 V52 +T0 R69 V14 +T0 R162 V68 +T0 R178 V38 +T0 R75 V63 +T0 R43 V17 +T0 R107 V210 +T0 R139 V20 +T0 R235 V1 +T0 R72 V144 +T0 R40 V16 +T0 R104 V97 +T0 R136 V67 +T0 R232 V2 +T0 R195 V60 +T0 R75 V17 +T0 R163 V177 +T0 R179 V50 +T0 R76 V63 +T0 R44 V18 +T0 R108 V243 +T0 R140 V69 +T0 R236 V1 +T0 R73 V143 +T0 R41 V146 +T0 R105 V97 +T0 R137 V67 +T0 R233 V1 +T0 R196 V60 +T0 R76 V17 +T0 R164 V182 +T0 R180 V50 +T0 R77 V63 +T0 R45 V37 +T0 R109 V197 +T0 R141 V245 +T0 R237 V3 +T0 R74 V4 +T0 R42 V39 +T0 R106 V227 +T0 R138 V242 +T0 R234 V2 +T0 R197 V58 +T0 R77 V14 +T0 R165 V177 +T0 R181 V46 +T0 R83 V63 +T0 R51 V37 +T0 R115 V197 +T0 R147 V245 +T0 R243 V2 +T0 R80 V4 +T0 R48 V38 +T0 R112 V243 +T0 R144 V242 +T0 R240 V2 +T0 R198 V56 +T0 R83 V14 +T0 R166 V105 +T0 R182 V46 +T0 R84 V63 +T0 R52 V17 +T0 R116 V179 +T0 R148 V182 +T0 R244 V5 +T0 R81 V94 +T0 R49 V25 +T0 R113 V148 +T0 R145 V230 +T0 R241 V2 +T0 R199 V48 +T0 R84 V12 +T0 R167 V177 +T0 R183 V38 +T0 R85 V63 +T0 R53 V33 +T0 R117 V147 +T0 R149 V247 +T0 R245 V0 +T0 R82 V7 +T0 R50 V33 +T0 R114 V250 +T0 R146 V119 +T0 R242 V0 +T0 R200 V48 +T0 R85 V12 +T0 R168 V177 +T0 R184 V38 +T0 R184 V6 +T0 R85 V63 +T0 R53 V48 +T0 R117 V192 +T0 R149 V104 +T0 R245 V3 +T0 R82 V191 +T0 R50 V32 +T0 R114 V193 +T0 R146 V155 +T0 R242 V3 +T0 R200 V48 +T0 R85 V15 +T0 R168 V177 +T0 R184 V38 +T0 R182 V14 +T0 R83 V127 +T0 R51 V84 +T0 R115 V240 +T0 R147 V247 +T0 R243 V2 +T0 R80 V64 +T0 R48 V17 +T0 R112 V129 +T0 R144 V247 +T0 R240 V0 +T0 R198 V58 +T0 R83 V79 +T0 R166 V4 +T0 R182 V42 +T0 R181 V14 +T0 R77 V127 +T0 R45 V84 +T0 R109 V240 +T0 R141 V247 +T0 R237 V2 +T0 R74 V64 +T0 R42 V17 +T0 R106 V129 +T0 R138 V247 +T0 R234 V0 +T0 R197 V58 +T0 R77 V79 +T0 R165 V177 +T0 R181 V42 +T0 R178 V6 +T0 R69 V127 +T0 R37 V84 +T0 R101 V240 +T0 R133 V247 +T0 R229 V2 +T0 R66 V64 +T0 R34 V17 +T0 R98 V129 +T0 R130 V247 +T0 R226 V0 +T0 R194 V58 +T0 R69 V79 +T0 R162 V4 +T0 R178 V46 +T0 R177 V18 +T0 R68 V63 +T0 R36 V113 +T0 R100 V53 +T0 R132 V42 +T0 R228 V0 +T0 R65 V192 +T0 R33 V241 +T0 R97 V110 +T0 R129 V141 +T0 R225 V0 +T0 R193 V62 +T0 R68 V13 +T0 R161 V177 +T0 R177 V50 +T0 R176 V18 +T0 R67 V63 +T0 R35 V113 +T0 R99 V53 +T0 R131 V42 +T0 R227 V0 +T0 R64 V192 +T0 R32 V241 +T0 R96 V110 +T0 R128 V141 +T0 R224 V0 +T0 R192 V62 +T0 R67 V13 +T0 R160 V4 +T0 R176 V50 +T108695 R179 V18 +T108695 R180 V18 +T217391 R75 V63 +T217391 R43 V113 +T217391 R107 V53 +T217391 R139 V42 +T217391 R235 V0 +T217391 R72 V192 +T217391 R40 V241 +T217391 R104 V110 +T217391 R136 V141 +T217391 R232 V0 +T217391 R195 V62 +T217391 R75 V26 +T217391 R163 V177 +T217391 R179 V50 +T217391 R183 V6 +T217391 R76 V63 +T217391 R44 V113 +T217391 R108 V53 +T217391 R140 V42 +T217391 R236 V0 +T217391 R73 V192 +T217391 R41 V241 +T217391 R105 V110 +T217391 R137 V141 +T217391 R233 V0 +T217391 R196 V62 +T217391 R76 V26 +T217391 R164 V4 +T217391 R180 V50 +T217391 R167 V177 +T217391 R183 V38 +T217391 R182 V10 +T217391 R181 V10 +T217391 R178 V14 +T217391 R83 V63 +T217391 R51 V0 +T217391 R115 V247 +T217391 R147 V151 +T217391 R243 V0 +T217391 R80 V1 +T217391 R48 V0 +T217391 R112 V201 +T217391 R144 V25 +T217391 R240 V0 +T217391 R198 V52 +T217391 R83 V14 +T217391 R166 V68 +T217391 R182 V38 +T217391 R165 V4 +T217391 R181 V42 +T217391 R69 V63 +T217391 R37 V64 +T217391 R101 V240 +T217391 R133 V247 +T217391 R229 V3 +T217391 R66 V64 +T217391 R34 V64 +T217391 R98 V129 +T217391 R130 V247 +T217391 R226 V0 +T217391 R194 V60 +T217391 R69 V15 +T217391 R162 V7 +T217391 R178 V46 +T217391 R178 V14 +T217391 R69 V127 +T217391 R37 V84 +T217391 R101 V240 +T217391 R133 V247 +T217391 R229 V2 +T217391 R66 V64 +T217391 R34 V17 +T217391 R98 V129 +T217391 R130 V247 +T217391 R226 V0 +T217391 R194 V58 +T217391 R69 V79 +T217391 R162 V177 +T217391 R178 V42 +T217391 R182 V6 +T217391 R83 V127 +T217391 R51 V84 +T217391 R115 V240 +T217391 R147 V247 +T217391 R243 V2 +T217391 R80 V64 +T217391 R48 V17 +T217391 R112 V129 +T217391 R144 V247 +T217391 R240 V0 +T217391 R198 V58 +T217391 R83 V79 +T217391 R166 V4 +T217391 R182 V46 +T326086 R183 V6 +T326086 R84 V63 +T326086 R52 V0 +T326086 R116 V247 +T326086 R148 V151 +T326086 R244 V0 +T326086 R81 V1 +T326086 R49 V0 +T326086 R113 V201 +T326086 R145 V25 +T326086 R241 V0 +T326086 R199 V52 +T326086 R84 V14 +T326086 R167 V68 +T326086 R183 V38 +T326086 R183 V6 +T326086 R84 V63 +T326086 R52 V17 +T326086 R116 V179 +T326086 R148 V182 +T326086 R244 V5 +T326086 R81 V94 +T326086 R49 V25 +T326086 R113 V148 +T326086 R145 V230 +T326086 R241 V2 +T326086 R199 V48 +T326086 R84 V12 +T326086 R167 V177 +T326086 R183 V38 +T434781 R183 V6 +T434781 R84 V63 +T434781 R52 V37 +T434781 R116 V197 +T434781 R148 V245 +T434781 R244 V3 +T434781 R81 V4 +T434781 R49 V39 +T434781 R113 V227 +T434781 R145 V242 +T434781 R241 V2 +T434781 R199 V58 +T434781 R84 V14 +T434781 R167 V177 +T434781 R183 V46 +T434781 R183 V14 +T434781 R84 V63 +T434781 R52 V17 +T434781 R116 V179 +T434781 R148 V182 +T434781 R244 V5 +T434781 R81 V94 +T434781 R49 V25 +T434781 R113 V148 +T434781 R145 V230 +T434781 R241 V2 +T434781 R199 V48 +T434781 R84 V12 +T434781 R167 V4 +T434781 R183 V42 +T434781 R184 V6 +T434781 R85 V63 +T434781 R53 V2 +T434781 R117 V181 +T434781 R149 V8 +T434781 R245 V6 +T434781 R82 V13 +T434781 R50 V1 +T434781 R114 V136 +T434781 R146 V239 +T434781 R242 V6 +T434781 R200 V48 +T434781 R85 V14 +T434781 R168 V6 +T434781 R184 V43 +T543476 R183 V10 +T543476 R167 V152 +T543476 R183 V39 +T652171 R183 V7 +T652172 R184 V11 +T652172 R84 V63 +T652172 R52 V0 +T652172 R116 V247 +T652172 R148 V151 +T652172 R244 V0 +T652172 R81 V1 +T652172 R49 V0 +T652172 R113 V201 +T652172 R145 V25 +T652172 R241 V0 +T652172 R199 V52 +T652172 R84 V14 +T652172 R167 V68 +T652172 R183 V38 +T652174 R182 V14 +T652174 R178 V10 +T652174 R181 V10 +T652174 R85 V127 +T652174 R53 V84 +T652174 R117 V240 +T652174 R149 V247 +T652174 R245 V2 +T652174 R82 V64 +T652174 R50 V17 +T652174 R114 V129 +T652174 R146 V247 +T652174 R242 V0 +T652174 R200 V58 +T652174 R85 V79 +T652174 R168 V4 +T652174 R184 V46 +T652174 R83 V63 +T652174 R51 V64 +T652174 R115 V240 +T652174 R147 V247 +T652174 R243 V3 +T652174 R80 V64 +T652174 R48 V64 +T652174 R112 V129 +T652174 R144 V247 +T652174 R240 V0 +T652174 R198 V60 +T652174 R83 V15 +T652174 R166 V7 +T652174 R182 V50 +T652174 R162 V177 +T652174 R178 V42 +T652174 R77 V63 +T652174 R45 V64 +T652174 R109 V240 +T652174 R141 V247 +T652174 R237 V3 +T652174 R74 V64 +T652174 R42 V64 +T652174 R106 V129 +T652174 R138 V247 +T652174 R234 V0 +T652174 R197 V60 +T652174 R77 V15 +T652174 R165 V181 +T652174 R181 V46 +T652174 R181 V14 +T652174 R77 V127 +T652174 R45 V84 +T652174 R109 V240 +T652174 R141 V247 +T652174 R237 V2 +T652174 R74 V64 +T652174 R42 V17 +T652174 R106 V129 +T652174 R138 V247 +T652174 R234 V0 +T652174 R197 V58 +T652174 R77 V79 +T652174 R165 V4 +T652174 R181 V42 +T760866 R181 V10 +T760866 R77 V63 +T760866 R45 V17 +T760866 R109 V179 +T760866 R141 V182 +T760866 R237 V5 +T760866 R74 V94 +T760866 R42 V25 +T760866 R106 V148 +T760866 R138 V230 +T760866 R234 V2 +T760866 R197 V48 +T760866 R77 V12 +T760866 R165 V152 +T760866 R181 V39 +T869563 R183 V6 +T869563 R84 V63 +T869563 R52 V37 +T869563 R116 V197 +T869563 R148 V245 +T869563 R244 V3 +T869563 R81 V4 +T869563 R49 V39 +T869563 R113 V227 +T869563 R145 V242 +T869563 R241 V2 +T869563 R199 V58 +T869563 R84 V14 +T869563 R167 V177 +T869563 R183 V46 +T869563 R183 V14 +T869563 R84 V63 +T869563 R52 V0 +T869563 R116 V247 +T869563 R148 V151 +T869563 R244 V0 +T869563 R81 V1 +T869563 R49 V0 +T869563 R113 V201 +T869563 R145 V25 +T869563 R241 V0 +T869563 R199 V52 +T869563 R84 V14 +T869563 R167 V68 +T869563 R183 V38 +T978257 R181 V7 +T978257 R165 V4 +T978257 R181 V42 +T978258 R183 V6 +T978258 R167 V68 +T978258 R183 V38 +T1086953 R183 V6 +T1086953 R84 V63 +T1086953 R52 V2 +T1086953 R116 V181 +T1086953 R148 V8 +T1086953 R244 V6 +T1086953 R81 V13 +T1086953 R49 V1 +T1086953 R113 V136 +T1086953 R145 V239 +T1086953 R241 V6 +T1086953 R199 V48 +T1086953 R84 V14 +T1086953 R167 V6 +T1086953 R183 V43 +T1086953 R183 V11 +T1086953 R84 V63 +T1086953 R52 V27 +T1086953 R116 V241 +T1086953 R148 V243 +T1086953 R244 V3 +T1086953 R81 V4 +T1086953 R49 V25 +T1086953 R113 V241 +T1086953 R145 V225 +T1086953 R241 V2 +T1086953 R199 V56 +T1086953 R84 V14 +T1086953 R167 V6 +T1086953 R183 V51 +T1086957 R177 V18 +T1086957 R178 V10 +T1086957 R176 V18 +T1086957 R184 V14 +T1086957 R182 V18 +T1086957 R68 V17 +T1086957 R161 V102 +T1086957 R177 V50 +T1086957 R162 V4 +T1086957 R178 V46 +T1086957 R67 V63 +T1086957 R35 V64 +T1086957 R99 V240 +T1086957 R131 V247 +T1086957 R227 V3 +T1086957 R64 V64 +T1086957 R32 V64 +T1086957 R96 V129 +T1086957 R128 V247 +T1086957 R224 V0 +T1086957 R192 V60 +T1086957 R67 V15 +T1086957 R160 V7 +T1086957 R176 V50 +T1086957 R85 V63 +T1086957 R53 V113 +T1086957 R117 V53 +T1086957 R149 V42 +T1086957 R245 V0 +T1086957 R82 V192 +T1086957 R50 V241 +T1086957 R114 V110 +T1086957 R146 V141 +T1086957 R242 V0 +T1086957 R200 V62 +T1086957 R85 V18 +T1086957 R168 V6 +T1086957 R184 V51 +T1086957 R83 V127 +T1086957 R51 V84 +T1086957 R115 V240 +T1086957 R147 V247 +T1086957 R243 V2 +T1086957 R80 V64 +T1086957 R48 V17 +T1086957 R112 V129 +T1086957 R144 V247 +T1086957 R240 V0 +T1086957 R198 V58 +T1086957 R83 V79 +T1086957 R166 V177 +T1086957 R182 V42 +T1086957 R182 V10 +T1086957 R166 V4 +T1086957 R182 V42 +T1195648 R183 V19 +T1195648 R181 V10 +T1195648 R84 V63 +T1195648 R52 V17 +T1195648 R116 V179 +T1195648 R148 V182 +T1195648 R244 V5 +T1195648 R81 V94 +T1195648 R49 V25 +T1195648 R113 V148 +T1195648 R145 V230 +T1195648 R241 V2 +T1195648 R199 V48 +T1195648 R84 V12 +T1195648 R167 V177 +T1195648 R183 V38 +T1195648 R77 V63 +T1195648 R45 V33 +T1195648 R109 V147 +T1195648 R141 V247 +T1195648 R237 V0 +T1195648 R74 V7 +T1195648 R42 V33 +T1195648 R106 V250 +T1195648 R138 V119 +T1195648 R234 V0 +T1195648 R197 V48 +T1195648 R77 V12 +T1195648 R165 V177 +T1195648 R181 V38 +T1304343 R183 V6 +T1304343 R181 V6 +T1304343 R84 V63 +T1304343 R52 V37 +T1304343 R116 V197 +T1304343 R148 V245 +T1304343 R244 V3 +T1304343 R81 V4 +T1304343 R49 V39 +T1304343 R113 V227 +T1304343 R145 V242 +T1304343 R241 V2 +T1304343 R199 V58 +T1304343 R84 V14 +T1304343 R167 V177 +T1304343 R183 V46 +T1304343 R77 V63 +T1304343 R45 V37 +T1304343 R109 V197 +T1304343 R141 V245 +T1304343 R237 V2 +T1304343 R74 V4 +T1304343 R42 V38 +T1304343 R106 V243 +T1304343 R138 V242 +T1304343 R234 V2 +T1304343 R197 V56 +T1304343 R77 V14 +T1304343 R165 V105 +T1304343 R181 V46 +T1304343 R181 V14 +T1304343 R77 V63 +T1304343 R45 V17 +T1304343 R109 V179 +T1304343 R141 V182 +T1304343 R237 V5 +T1304343 R74 V94 +T1304343 R42 V25 +T1304343 R106 V148 +T1304343 R138 V230 +T1304343 R234 V2 +T1304343 R197 V48 +T1304343 R77 V12 +T1304343 R165 V4 +T1304343 R181 V42 +T1304348 R180 V18 +T1304348 R179 V18 +T1304348 R76 V30 +T1304348 R164 V6 +T1304348 R180 V51 +T1304348 R75 V29 +T1304348 R163 V102 +T1304348 R179 V50 +T1413038 R183 V14 +T1413038 R84 V63 +T1413038 R52 V2 +T1413038 R116 V181 +T1413038 R148 V8 +T1413038 R244 V6 +T1413038 R81 V13 +T1413038 R49 V1 +T1413038 R113 V136 +T1413038 R145 V239 +T1413038 R241 V6 +T1413038 R199 V48 +T1413038 R84 V14 +T1413038 R167 V6 +T1413038 R183 V43 +T1413038 R181 V10 +T1413038 R77 V63 +T1413038 R45 V27 +T1413038 R109 V241 +T1413038 R141 V243 +T1413038 R237 V3 +T1413038 R74 V4 +T1413038 R42 V25 +T1413038 R106 V241 +T1413038 R138 V225 +T1413038 R234 V2 +T1413038 R197 V56 +T1413038 R77 V14 +T1413038 R165 V6 +T1413038 R181 V51 +T1413038 R181 V19 +T1413038 R77 V63 +T1413038 R45 V17 +T1413038 R109 V179 +T1413038 R141 V182 +T1413038 R237 V5 +T1413038 R74 V94 +T1413038 R42 V25 +T1413038 R106 V148 +T1413038 R138 V230 +T1413038 R234 V2 +T1413038 R197 V48 +T1413038 R77 V12 +T1413038 R165 V177 +T1413038 R181 V38 +T1413044 R182 V10 +T1413044 R184 V19 +T1413044 R177 V18 +T1413044 R178 V14 +T1413044 R176 V18 +T1413044 R83 V63 +T1413044 R51 V113 +T1413044 R115 V53 +T1413044 R147 V42 +T1413044 R243 V0 +T1413044 R80 V192 +T1413044 R48 V241 +T1413044 R112 V110 +T1413044 R144 V141 +T1413044 R240 V0 +T1413044 R198 V62 +T1413044 R83 V18 +T1413044 R166 V52 +T1413044 R182 V51 +T1413044 R85 V127 +T1413044 R53 V84 +T1413044 R117 V240 +T1413044 R149 V247 +T1413044 R245 V2 +T1413044 R82 V64 +T1413044 R50 V17 +T1413044 R114 V129 +T1413044 R146 V247 +T1413044 R242 V0 +T1413044 R200 V58 +T1413044 R85 V79 +T1413044 R168 V4 +T1413044 R184 V46 +T1413044 R68 V63 +T1413044 R36 V64 +T1413044 R100 V240 +T1413044 R132 V247 +T1413044 R228 V3 +T1413044 R65 V64 +T1413044 R33 V64 +T1413044 R97 V129 +T1413044 R129 V247 +T1413044 R225 V0 +T1413044 R193 V60 +T1413044 R68 V15 +T1413044 R161 V7 +T1413044 R177 V50 +T1413044 R69 V63 +T1413044 R37 V113 +T1413044 R101 V53 +T1413044 R133 V42 +T1413044 R229 V0 +T1413044 R66 V192 +T1413044 R34 V241 +T1413044 R98 V110 +T1413044 R130 V141 +T1413044 R226 V0 +T1413044 R194 V62 +T1413044 R69 V14 +T1413044 R162 V102 +T1413044 R178 V50 +T1413044 R67 V127 +T1413044 R35 V84 +T1413044 R99 V240 +T1413044 R131 V247 +T1413044 R227 V2 +T1413044 R64 V64 +T1413044 R32 V17 +T1413044 R96 V129 +T1413044 R128 V247 +T1413044 R224 V0 +T1413044 R192 V58 +T1413044 R67 V79 +T1413044 R160 V177 +T1413044 R176 V42 +T1413044 R176 V10 +T1413044 R160 V4 +T1413044 R176 V42 +T1630428 R183 V11 +T1630429 R181 V6 +T1630429 R84 V63 +T1630429 R52 V17 +T1630429 R116 V179 +T1630429 R148 V182 +T1630429 R244 V5 +T1630429 R81 V94 +T1630429 R49 V25 +T1630429 R113 V148 +T1630429 R145 V230 +T1630429 R241 V2 +T1630429 R199 V48 +T1630429 R84 V12 +T1630429 R167 V4 +T1630429 R183 V42 +T1630429 R77 V63 +T1630429 R45 V33 +T1630429 R109 V147 +T1630429 R141 V247 +T1630429 R237 V0 +T1630429 R74 V7 +T1630429 R42 V33 +T1630429 R106 V250 +T1630429 R138 V119 +T1630429 R234 V0 +T1630429 R197 V48 +T1630429 R77 V12 +T1630429 R165 V4 +T1630429 R181 V42 +T1630435 R179 V18 +T1630435 R180 V19 +T1630435 R75 V27 +T1630435 R163 V102 +T1630435 R179 V50 +T1630435 R76 V29 +T1630435 R164 V52 +T1630435 R180 V51 +T1739123 R180 V19 +T1739123 R76 V63 +T1739123 R44 V1 +T1739123 R108 V242 +T1739123 R140 V84 +T1739123 R236 V3 +T1739123 R73 V0 +T1739123 R41 V8 +T1739123 R105 V241 +T1739123 R137 V146 +T1739123 R233 V2 +T1739123 R196 V56 +T1739123 R76 V14 +T1739123 R164 V6 +T1739123 R180 V51 +T1739123 R180 V19 +T1739123 R76 V63 +T1739123 R44 V37 +T1739123 R108 V197 +T1739123 R140 V245 +T1739123 R236 V3 +T1739123 R73 V4 +T1739123 R41 V39 +T1739123 R105 V227 +T1739123 R137 V242 +T1739123 R233 V2 +T1739123 R196 V58 +T1739123 R76 V14 +T1739123 R164 V177 +T1739123 R180 V46 +T1739123 R180 V14 +T1739123 R76 V63 +T1739123 R44 V0 +T1739123 R108 V247 +T1739123 R140 V151 +T1739123 R236 V0 +T1739123 R73 V1 +T1739123 R41 V0 +T1739123 R105 V201 +T1739123 R137 V25 +T1739123 R233 V0 +T1739123 R196 V52 +T1739123 R76 V14 +T1739123 R164 V68 +T1739123 R180 V38 +T1739124 R183 V10 +T1739124 R181 V10 +T1739124 R167 V102 +T1739124 R183 V38 +T1739124 R165 V102 +T1739124 R181 V38 +T1739131 R176 V10 +T1739131 R67 V63 +T1739131 R35 V17 +T1739131 R99 V210 +T1739131 R131 V20 +T1739131 R227 V1 +T1739131 R64 V144 +T1739131 R32 V16 +T1739131 R96 V97 +T1739131 R128 V67 +T1739131 R224 V2 +T1739131 R192 V60 +T1739131 R67 V16 +T1739131 R160 V177 +T1739131 R176 V50 +T1739131 R178 V18 +T1739131 R184 V14 +T1739131 R177 V18 +T1739131 R182 V19 +T1739131 R69 V127 +T1739131 R37 V84 +T1739131 R101 V240 +T1739131 R133 V247 +T1739131 R229 V2 +T1739131 R66 V64 +T1739131 R34 V17 +T1739131 R98 V129 +T1739131 R130 V247 +T1739131 R226 V0 +T1739131 R194 V58 +T1739131 R69 V79 +T1739131 R162 V152 +T1739131 R178 V39 +T1739131 R85 V63 +T1739131 R53 V64 +T1739131 R117 V240 +T1739131 R149 V247 +T1739131 R245 V3 +T1739131 R82 V64 +T1739131 R50 V64 +T1739131 R114 V129 +T1739131 R146 V247 +T1739131 R242 V0 +T1739131 R200 V60 +T1739131 R85 V15 +T1739131 R168 V157 +T1739131 R184 V43 +T1739131 R68 V63 +T1739131 R36 V113 +T1739131 R100 V53 +T1739131 R132 V42 +T1739131 R228 V0 +T1739131 R65 V192 +T1739131 R33 V241 +T1739131 R97 V110 +T1739131 R129 V141 +T1739131 R225 V0 +T1739131 R193 V62 +T1739131 R68 V13 +T1739131 R161 V177 +T1739131 R177 V50 +T1739131 R83 V127 +T1739131 R51 V84 +T1739131 R115 V240 +T1739131 R147 V247 +T1739131 R243 V2 +T1739131 R80 V64 +T1739131 R48 V17 +T1739131 R112 V129 +T1739131 R144 V247 +T1739131 R240 V0 +T1739131 R198 V58 +T1739131 R83 V79 +T1739131 R166 V152 +T1739131 R182 V43 +T1739131 R182 V11 +T1739131 R83 V63 +T1739131 R51 V113 +T1739131 R115 V53 +T1739131 R147 V42 +T1739131 R243 V0 +T1739131 R80 V192 +T1739131 R48 V241 +T1739131 R112 V110 +T1739131 R144 V141 +T1739131 R240 V0 +T1739131 R198 V62 +T1739131 R83 V12 +T1739131 R166 V152 +T1739131 R182 V51 +T1739131 R184 V11 +T1739131 R85 V127 +T1739131 R53 V84 +T1739131 R117 V240 +T1739131 R149 V247 +T1739131 R245 V2 +T1739131 R82 V64 +T1739131 R50 V17 +T1739131 R114 V129 +T1739131 R146 V247 +T1739131 R242 V0 +T1739131 R200 V58 +T1739131 R85 V79 +T1739131 R168 V102 +T1739131 R184 V42 +T1739132 R176 V18 +T1739132 R67 V63 +T1739132 R35 V48 +T1739132 R99 V192 +T1739132 R131 V104 +T1739132 R227 V3 +T1739132 R64 V191 +T1739132 R32 V32 +T1739132 R96 V193 +T1739132 R128 V155 +T1739132 R224 V3 +T1739132 R192 V48 +T1739132 R67 V15 +T1739132 R160 V102 +T1739132 R176 V38 +T1956514 R180 V6 +T1956514 R164 V68 +T1956514 R180 V38 +T1956515 R183 V6 +T1956515 R181 V6 +T1956515 R167 V102 +T1956515 R183 V38 +T1956515 R165 V102 +T1956515 R181 V38 +T1956522 R184 V10 +T1956522 R179 V18 +T1956522 R85 V63 +T1956522 R53 V113 +T1956522 R117 V53 +T1956522 R149 V42 +T1956522 R245 V0 +T1956522 R82 V192 +T1956522 R50 V241 +T1956522 R114 V110 +T1956522 R146 V141 +T1956522 R242 V0 +T1956522 R200 V62 +T1956522 R85 V25 +T1956522 R168 V152 +T1956522 R184 V51 +T1956522 R178 V7 +T1956522 R75 V26 +T1956522 R163 V177 +T1956522 R179 V50 +T1956522 R162 V152 +T1956522 R178 V39 +T1956522 R179 V18 +T1956522 R75 V127 +T1956522 R43 V84 +T1956522 R107 V240 +T1956522 R139 V247 +T1956522 R235 V2 +T1956522 R72 V64 +T1956522 R40 V17 +T1956522 R104 V129 +T1956522 R136 V247 +T1956522 R232 V0 +T1956522 R195 V58 +T1956522 R75 V79 +T1956522 R163 V152 +T1956522 R179 V43 +T1956522 R184 V19 +T1956522 R85 V127 +T1956522 R53 V84 +T1956522 R117 V240 +T1956522 R149 V247 +T1956522 R245 V2 +T1956522 R82 V64 +T1956522 R50 V17 +T1956522 R114 V129 +T1956522 R146 V247 +T1956522 R242 V0 +T1956522 R200 V58 +T1956522 R85 V79 +T1956522 R168 V102 +T1956522 R184 V42 +T2065209 R180 V6 +T2065209 R164 V68 +T2065209 R180 V38 +T2065209 R180 V6 +T2065209 R76 V63 +T2065209 R44 V0 +T2065209 R108 V246 +T2065209 R140 V4 +T2065209 R236 V1 +T2065209 R73 V12 +T2065209 R41 V1 +T2065209 R105 V246 +T2065209 R137 V8 +T2065209 R233 V5 +T2065209 R196 V56 +T2065209 R76 V14 +T2065209 R164 V4 +T2065209 R180 V46 +T2065210 R183 V6 +T2065210 R181 V6 +T2065210 R167 V102 +T2065210 R183 V38 +T2065210 R165 V102 +T2065210 R181 V38 +T2173904 R181 V6 +T2173904 R77 V63 +T2173904 R45 V2 +T2173904 R109 V181 +T2173904 R141 V8 +T2173904 R237 V6 +T2173904 R74 V13 +T2173904 R42 V1 +T2173904 R106 V136 +T2173904 R138 V239 +T2173904 R234 V6 +T2173904 R197 V48 +T2173904 R77 V14 +T2173904 R165 V6 +T2173904 R181 V43 +T2173904 R181 V11 +T2173904 R77 V63 +T2173904 R45 V37 +T2173904 R109 V197 +T2173904 R141 V245 +T2173904 R237 V3 +T2173904 R74 V4 +T2173904 R42 V39 +T2173904 R106 V227 +T2173904 R138 V242 +T2173904 R234 V2 +T2173904 R197 V58 +T2173904 R77 V14 +T2173904 R165 V177 +T2173904 R181 V46 +T2173905 R183 V6 +T2173905 R167 V152 +T2173905 R183 V39 +T2282599 R180 V14 +T2282600 R183 V7 +T2282600 R76 V63 +T2282600 R44 V17 +T2282600 R108 V179 +T2282600 R140 V182 +T2282600 R236 V5 +T2282600 R73 V94 +T2282600 R41 V25 +T2282600 R105 V148 +T2282600 R137 V230 +T2282600 R233 V2 +T2282600 R196 V48 +T2282600 R76 V12 +T2282600 R164 V102 +T2282600 R180 V42 +T2282600 R84 V63 +T2282600 R52 V33 +T2282600 R116 V147 +T2282600 R148 V247 +T2282600 R244 V0 +T2282600 R81 V7 +T2282600 R49 V33 +T2282600 R113 V250 +T2282600 R145 V119 +T2282600 R241 V0 +T2282600 R199 V48 +T2282600 R84 V12 +T2282600 R167 V102 +T2282600 R183 V42 +T2391294 R181 V14 +T2391294 R77 V63 +T2391294 R45 V0 +T2391294 R109 V247 +T2391294 R141 V151 +T2391294 R237 V0 +T2391294 R74 V1 +T2391294 R42 V0 +T2391294 R106 V201 +T2391294 R138 V25 +T2391294 R234 V0 +T2391294 R197 V52 +T2391294 R77 V14 +T2391294 R165 V68 +T2391294 R181 V38 +T2391294 R181 V6 +T2391294 R77 V63 +T2391294 R45 V0 +T2391294 R109 V246 +T2391294 R141 V4 +T2391294 R237 V1 +T2391294 R74 V12 +T2391294 R42 V1 +T2391294 R106 V246 +T2391294 R138 V8 +T2391294 R234 V5 +T2391294 R197 V56 +T2391294 R77 V14 +T2391294 R165 V218 +T2391294 R181 V42 +T2391295 R180 V10 +T2391295 R183 V10 +T2391305 R184 V10 +T2391305 R179 V11 +T2391305 R178 V7 +T2391305 R76 V127 +T2391305 R44 V84 +T2391305 R108 V240 +T2391305 R140 V247 +T2391305 R236 V2 +T2391305 R73 V64 +T2391305 R41 V17 +T2391305 R105 V129 +T2391305 R137 V247 +T2391305 R233 V0 +T2391305 R196 V58 +T2391305 R76 V79 +T2391305 R164 V102 +T2391305 R180 V42 +T2391305 R84 V63 +T2391305 R52 V64 +T2391305 R116 V240 +T2391305 R148 V247 +T2391305 R244 V3 +T2391305 R81 V64 +T2391305 R49 V64 +T2391305 R113 V129 +T2391305 R145 V247 +T2391305 R241 V0 +T2391305 R199 V60 +T2391305 R84 V15 +T2391305 R167 V105 +T2391305 R183 V46 +T2391305 R168 V152 +T2391305 R184 V43 +T2391305 R75 V63 +T2391305 R43 V64 +T2391305 R107 V240 +T2391305 R139 V247 +T2391305 R235 V3 +T2391305 R72 V64 +T2391305 R40 V64 +T2391305 R104 V129 +T2391305 R136 V247 +T2391305 R232 V0 +T2391305 R195 V60 +T2391305 R75 V15 +T2391305 R163 V157 +T2391305 R179 V47 +T2391305 R162 V152 +T2391305 R178 V39 +T2499990 R178 V7 +T2499990 R69 V63 +T2499990 R37 V17 +T2499990 R101 V179 +T2499990 R133 V182 +T2499990 R229 V5 +T2499990 R66 V94 +T2499990 R34 V25 +T2499990 R98 V148 +T2499990 R130 V230 +T2499990 R226 V2 +T2499990 R194 V48 +T2499990 R69 V12 +T2499990 R162 V152 +T2499990 R178 V39 +T2608685 R181 V10 +T2608685 R77 V63 +T2608685 R45 V37 +T2608685 R109 V197 +T2608685 R141 V245 +T2608685 R237 V3 +T2608685 R74 V4 +T2608685 R42 V39 +T2608685 R106 V227 +T2608685 R138 V242 +T2608685 R234 V2 +T2608685 R197 V58 +T2608685 R77 V14 +T2608685 R165 V177 +T2608685 R181 V46 +T2608685 R181 V14 +T2608685 R77 V63 +T2608685 R45 V0 +T2608685 R109 V247 +T2608685 R141 V151 +T2608685 R237 V0 +T2608685 R74 V1 +T2608685 R42 V0 +T2608685 R106 V201 +T2608685 R138 V25 +T2608685 R234 V0 +T2608685 R197 V52 +T2608685 R77 V14 +T2608685 R165 V68 +T2608685 R181 V38 +T2717380 R181 V6 +T2717380 R165 V68 +T2717380 R181 V38 +T2717381 R178 V7 +T2717381 R162 V52 +T2717381 R178 V39 +T2826075 R181 V6 +T2826075 R77 V63 +T2826075 R45 V0 +T2826075 R109 V246 +T2826075 R141 V4 +T2826075 R237 V1 +T2826075 R74 V12 +T2826075 R42 V1 +T2826075 R106 V246 +T2826075 R138 V8 +T2826075 R234 V5 +T2826075 R197 V56 +T2826075 R77 V14 +T2826075 R165 V4 +T2826075 R181 V46 +T2826075 R181 V14 +T2826075 R77 V63 +T2826075 R45 V2 +T2826075 R109 V181 +T2826075 R141 V8 +T2826075 R237 V6 +T2826075 R74 V13 +T2826075 R42 V1 +T2826075 R106 V136 +T2826075 R138 V239 +T2826075 R234 V6 +T2826075 R197 V48 +T2826075 R77 V14 +T2826075 R165 V6 +T2826075 R181 V43 +T2826075 R181 V11 +T2826075 R77 V63 +T2826075 R45 V27 +T2826075 R109 V241 +T2826075 R141 V243 +T2826075 R237 V3 +T2826075 R74 V4 +T2826075 R42 V25 +T2826075 R106 V241 +T2826075 R138 V225 +T2826075 R234 V2 +T2826075 R197 V56 +T2826075 R77 V14 +T2826075 R165 V6 +T2826075 R181 V51 +T2826088 R177 V18 +T2826088 R182 V19 +T2826088 R184 V11 +T2826088 R179 V15 +T2826088 R68 V14 +T2826088 R161 V52 +T2826088 R177 V51 +T2826088 R180 V10 +T2826088 R183 V14 +T2826088 R83 V18 +T2826088 R166 V102 +T2826088 R182 V50 +T2826088 R168 V102 +T2826088 R184 V42 +T2826088 R163 V105 +T2826088 R179 V46 +T2826088 R164 V152 +T2826088 R180 V43 +T2826088 R167 V157 +T2826088 R183 V47 +T2826088 R183 V15 +T2826088 R84 V127 +T2826088 R52 V84 +T2826088 R116 V240 +T2826088 R148 V247 +T2826088 R244 V2 +T2826088 R81 V64 +T2826088 R49 V17 +T2826088 R113 V129 +T2826088 R145 V247 +T2826088 R241 V0 +T2826088 R199 V58 +T2826088 R84 V79 +T2826088 R167 V152 +T2826088 R183 V39 +T2934770 R181 V19 +T2934772 R178 V7 +T2934772 R77 V63 +T2934772 R45 V17 +T2934772 R109 V179 +T2934772 R141 V182 +T2934772 R237 V5 +T2934772 R74 V94 +T2934772 R42 V25 +T2934772 R106 V148 +T2934772 R138 V230 +T2934772 R234 V2 +T2934772 R197 V48 +T2934772 R77 V12 +T2934772 R165 V102 +T2934772 R181 V38 +T2934772 R69 V63 +T2934772 R37 V33 +T2934772 R101 V147 +T2934772 R133 V247 +T2934772 R229 V0 +T2934772 R66 V7 +T2934772 R34 V33 +T2934772 R98 V250 +T2934772 R130 V119 +T2934772 R226 V0 +T2934772 R194 V48 +T2934772 R69 V12 +T2934772 R162 V102 +T2934772 R178 V38 diff --git a/music_bridge.go b/music_bridge.go new file mode 100644 index 0000000..f086b90 --- /dev/null +++ b/music_bridge.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// music_bridge.go wires the pure-Go OPL music path into the transpiled engine. +// The engine's initMusicModule() (in doom.go) calls installMusicModule when it +// is non-nil; music_opl.go sets it to install an OPL2/OPL3 synth driving the +// DMX GENMIDI bank (the chocolate-doom OPL path), keeping the synth entirely +// out of the generated blob. + +package gore + +// installMusicModule, when non-nil, is invoked by initMusicModule() to install +// the active music_module. It is set from music_opl.go's init(). +var installMusicModule func() diff --git a/music_opl.go b/music_opl.go new file mode 100644 index 0000000..760fc39 --- /dev/null +++ b/music_opl.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// music_opl.go installs the pure-Go OPL2/OPL3 music module: it synthesises the +// DMX MUS / MIDI music lumps through a Yamaha YMF262 emulator driving the DMX +// GENMIDI instrument bank, exactly like chocolate-doom's OPL music path, with +// CGO disabled. PCM is pulled by the host frontend through ReadMusicPCM. + +package gore + +import ( + "sync" + + "github.com/go-doom/engine/music/oplplayer" +) + +// MusicSampleRate is the stereo sample rate (Hz) at which the OPL music module +// renders. The host frontend should pull ReadMusicPCM at this rate. +const MusicSampleRate = 44100 + +// oplMusicOPL3 selects OPL3 (18-voice) synthesis when true, matching +// chocolate-doom's DMXOPTION "-opl3" extension; OPL2 (9-voice) otherwise. +var oplMusicOPL3 = true + +var ( + musicMu sync.Mutex + oplPlayer *oplplayer.Player +) + +func init() { + installMusicModule = installOPLMusicModule +} + +// installOPLMusicModule is invoked by the engine's initMusicModule() (when +// music is enabled) to make the OPL synth the active music_module. +func installOPLMusicModule() { + music_module = &oplMusicModule +} + +var oplMusicModule = music_module_t{ + Fnum_sound_devices: 2, + FInit: oplMusicInit, + FShutdown: oplMusicShutdown, + FSetMusicVolume: oplMusicSetVolume, + FPauseMusic: oplMusicPause, + FResumeMusic: oplMusicResume, + FRegisterSong: oplMusicRegisterSong, + FUnRegisterSong: oplMusicUnRegisterSong, + FPlaySong: oplMusicPlaySong, + FStopSong: oplMusicStopSong, + FPoll: nil, +} + +func oplMusicInit() { + musicMu.Lock() + defer musicMu.Unlock() + lump := w_CheckNumForName("GENMIDI") + if lump < 0 { + return // no GENMIDI bank -> music stays silent, engine runs on + } + p, err := oplplayer.New(w_CacheLumpNumBytes(lump), MusicSampleRate, oplMusicOPL3) + if err != nil { + return + } + oplPlayer = p +} + +func oplMusicShutdown() { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.Stop() + oplPlayer = nil + } +} + +func oplMusicSetVolume(volume int32) { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.SetVolume(int(volume)) + } +} + +func oplMusicPause() { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.Pause() + } +} + +func oplMusicResume() { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.Resume() + } +} + +// oplMusicRegisterSong parses a MUS or MIDI lump and prepares it for playback. +// It returns a non-zero handle on success (the OPL player holds one song at a +// time, matching DMX), or 0 on failure. +func oplMusicRegisterSong(data []byte) uintptr { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer == nil { + return 0 + } + if err := oplPlayer.RegisterSong(data); err != nil { + return 0 + } + return 1 +} + +func oplMusicUnRegisterSong(handle uintptr) { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.Stop() + } +} + +func oplMusicPlaySong(handle uintptr, looping boolean) boolean { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer == nil { + return 0 + } + oplPlayer.Play(looping != 0) + return 1 +} + +func oplMusicStopSong() { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer != nil { + oplPlayer.Stop() + } +} + +// ReadMusicPCM renders interleaved stereo int16 music PCM into buf (len must be +// even), advancing the OPL player's clock, and returns the number of stereo +// FRAMES written. It is safe to call from a host audio-callback goroutine. If no +// song is playing it zero-fills buf and returns len(buf)/2. This is the seam by +// which a frontend mixes music alongside SFX, mirroring chocolate-doom's OPL +// audio callback. +func ReadMusicPCM(buf []int16) int { + musicMu.Lock() + defer musicMu.Unlock() + if oplPlayer == nil { + for i := range buf { + buf[i] = 0 + } + return len(buf) / 2 + } + return oplPlayer.Read(buf) +} From 816fd4a8212b635b1118e54dc7418573163e8a3f Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 27 Jul 2026 16:56:48 +0200 Subject: [PATCH 2/3] feat(netgame): classic doomcom/ticcmd lockstep multiplayer (pure-Go) Implement the classic DOOM netgame protocol as a self-contained, testable pure-Go package (CGO=0), modelling id Software's d_net.c / i_net.c doomcom / doomdata / ticcmd lockstep exchange (chocolate-doom-faithful behaviour). - ticcmd.go: Ticcmd + byte-faithful serialization, identical to the engine's own vanilla 8-byte saveg_write_ticcmd_t layout. - packet.go: Doomdata packet (vanilla checksum/flags word, retransmitfrom, starttic, player, numtics, cmds[]) encode/decode with the NCMD_* command flags and the vanilla running checksum. - doomcom.go: Doomcom descriptor + shared Config. - transport.go: injectable Transport seam -- in-memory mesh (deterministic drop/reorder for tests) and a real net.UDPConn transport. - net.go: deterministic lockstep loop -- handshake/node discovery, per-tic ticcmd exchange, maketic/gametic advancement with a BACKUPTICS=128 send window, unsolicited + explicit NCMD_RETRANSMIT resend/backoff, the classic consistancy check (DesyncError), and lost-node TimeoutError. No crashes. Differential oracle: two, three and four in-process engines fed identical deterministic inputs over the in-memory transport stay in lockstep with byte-identical per-tic state-hash histories and identical final state; 30% packet loss and reorder still converge identically; an injected desync is detected. Coverage 98.3% (residual = defensive error branches unreachable with valid inputs -- Encode/Unmarshal that never fail on <=128-cmd windows). Builds CGO=0 on all six 64-bit arches. BSD-3-Clause (original work). Co-Authored-By: Claude Opus 4.8 --- netgame/doomcom.go | 25 +++ netgame/game.go | 64 ++++++ netgame/net.go | 458 ++++++++++++++++++++++++++++++++++++++ netgame/net_test.go | 390 ++++++++++++++++++++++++++++++++ netgame/packet.go | 133 +++++++++++ netgame/ticcmd.go | 85 +++++++ netgame/transport.go | 214 ++++++++++++++++++ netgame/transport_test.go | 182 +++++++++++++++ netgame/wire_test.go | 161 ++++++++++++++ 9 files changed, 1712 insertions(+) create mode 100644 netgame/doomcom.go create mode 100644 netgame/game.go create mode 100644 netgame/net.go create mode 100644 netgame/net_test.go create mode 100644 netgame/packet.go create mode 100644 netgame/ticcmd.go create mode 100644 netgame/transport.go create mode 100644 netgame/transport_test.go create mode 100644 netgame/wire_test.go diff --git a/netgame/doomcom.go b/netgame/doomcom.go new file mode 100644 index 0000000..6522199 --- /dev/null +++ b/netgame/doomcom.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +// Doomcom is the per-node game descriptor negotiated during the handshake, +// modelled on vanilla doomcom_t. Every node ends up with the same NumNodes, +// NumPlayers, TicDup, ExtraTics and DeathMatch; only ConsolePlayer differs +// (each node's own arbitrated player index). +type Doomcom struct { + NumNodes int // total participating nodes (== NumPlayers here) + NumPlayers int // total players + ConsolePlayer int // this node's player index (0-based, lowest-id-first) + TicDup int // run 1-in-N tics, duplicating (vanilla ticdup) + ExtraTics int // redundant tics sent per packet (vanilla extratics) + DeathMatch int // 0 = co-op, 1/2 = deathmatch modes +} + +// Config holds the shared parameters that all nodes agree on during Handshake. +type Config struct { + TicDup int + ExtraTics int + DeathMatch int +} diff --git a/netgame/game.go b/netgame/game.go new file mode 100644 index 0000000..42eadaf --- /dev/null +++ b/netgame/game.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "encoding/binary" + "hash/fnv" +) + +// Game is the pluggable deterministic world model advanced by the lockstep +// loop. Step advances the state by exactly one tic using every player's ticcmd +// for that tic (indexed by player). Hash returns a 64-bit digest of the current +// state (the state BEFORE the next tic runs); it must be a pure function of the +// tics applied so far, so that two nodes fed identical ticcmd streams always +// report identical hashes. The low 16 bits of Hash are used as the vanilla +// consistancy value. +type Game interface { + Step(cmds []Ticcmd) + Hash() uint64 +} + +// fnvOffset64 is the FNV-1a 64-bit offset basis, used as the default seed. +const fnvOffset64 = 1469598103934665603 + +// HashGame is a tiny deterministic reference "game": its entire state is a +// rolling 64-bit hash into which every player's ticcmd is mixed each tic. It is +// used by the tests as the differential oracle for lockstep determinism, and +// makes a fine stand-in wherever a real world simulation is not needed. +// +// The consistancy field of each ticcmd is deliberately excluded from the state +// evolution: consistancy is a CHECK on the state, not an INPUT to it. +type HashGame struct { + state uint64 +} + +// NewHashGame returns a HashGame seeded with seed (0 maps to the FNV offset +// basis so the zero value is still a valid, non-degenerate seed). +func NewHashGame(seed uint64) *HashGame { + if seed == 0 { + seed = fnvOffset64 + } + return &HashGame{state: seed} +} + +// Hash returns the current rolling state. +func (g *HashGame) Hash() uint64 { return g.state } + +// Step folds every player's ticcmd for this tic into the rolling state. +func (g *HashGame) Step(cmds []Ticcmd) { + h := fnv.New64a() + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], g.state) + _, _ = h.Write(buf[:]) + for i := range cmds { + b, _ := cmds[i].MarshalBinary() + // Zero the consistancy bytes (offsets 4,5): the check field must not + // influence the simulated state. + b[4], b[5] = 0, 0 + _, _ = h.Write(b) + } + g.state = h.Sum64() +} diff --git a/netgame/net.go b/netgame/net.go new file mode 100644 index 0000000..6a69b8d --- /dev/null +++ b/netgame/net.go @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "errors" + "fmt" +) + +// Protocol constants, modelled on vanilla d_net.c. +const ( + // BACKUPTICS is the vanilla send/receive window and the maximum span of a + // single retransmit or the number of tics a node may buffer ahead. + BACKUPTICS = 128 + + // ticLead is how many tics a node may produce (maketic) ahead of the tic it + // has simulated (gametic). It doubles as the consistancy lag: the + // consistancy carried by a ticcmd for tic T is the state hash at tic + // max(0, T-ticLead), which every synced node can compute by the time it + // produces (and later runs) tic T. The invariant lag == lead is required so + // the lagged hash is always available on both sides. + ticLead = 3 + + // windowTics is how many recent tics each NetUpdate re-sends unsolicited, so + // isolated packet losses recover without an explicit retransmit request. + windowTics = 12 + + // timeoutRounds is how many scheduler rounds with zero global progress are + // tolerated before RunGame reports a lost node. + timeoutRounds = 64 +) + +// Loop errors. +var ( + // ErrMaxRounds is returned when RunGame exhausts its round budget without + // every node reaching the target tic (and without a cleaner diagnosis). + ErrMaxRounds = errors.New("netgame: exceeded maximum scheduler rounds") + // ErrHandshake is returned when the handshake fails to converge. + ErrHandshake = errors.New("netgame: handshake did not converge") +) + +// DesyncError reports a consistancy-check failure: player Player's ticcmd for +// tic Tic carried consistancy Got, but node Node computed Want for the state at +// that tic. It signals a divergence between nodes — the game must stop, not +// crash. +type DesyncError struct { + Node int + Player int + Tic int + Got uint16 + Want uint16 +} + +func (e *DesyncError) Error() string { + return fmt.Sprintf("netgame: consistancy failure at node %d, player %d, tic %d: got %#04x want %#04x", + e.Node, e.Player, e.Tic, e.Got, e.Want) +} + +// TimeoutError reports that node Node could not obtain player Player's ticcmd +// for tic Tic within the timeout — a lost or unreachable node. +type TimeoutError struct { + Node int + Player int + Tic int +} + +func (e *TimeoutError) Error() string { + return fmt.Sprintf("netgame: node %d timed out waiting for player %d at tic %d", + e.Node, e.Player, e.Tic) +} + +// BuildFunc produces this node's local ticcmd for the given tic. It must be a +// pure, deterministic function of tic (and the node's own player index, which +// is captured by the closure). The consistancy field is filled in by the loop +// and may be left zero. +type BuildFunc func(tic int) Ticcmd + +// Stats records per-node counters for observability and tests. +type Stats struct { + PacketsSent int + PacketsRecv int + PacketsDropped int // packets that failed to decode (e.g. bad checksum) + TicsRun int + Resends int // retransmit requests this node issued +} + +// Node is one participant in the lockstep game. It owns its Doomcom, a Transport +// to its peers, a Game (the world model) and a BuildFunc (its input source). The +// scheduler (RunGame) drives every node through netUpdate/pump/tryRun each round. +type Node struct { + id int + numNodes int + tr Transport + game Game + build BuildFunc + + maketic int + gametic int + target int + round int + + // cmds[player][tic] -> ticcmd received (or locally produced for own player). + cmds []map[int]Ticcmd + // hashes[k] is the 64-bit state hash after k tics have run (hashes[0] is the + // initial state). uint16(hashes[t]) is the consistancy value for tic t. + hashes []uint64 + + // pendingReq[dest] is the lowest retransmit-from requested by dest (-1 none). + pendingReq []int + exited []bool + + stats Stats +} + +// NewNode builds a node from a negotiated Doomcom, a transport, a game model and +// a build function. The transport must deliver packets tagged with the sender's +// player index (as MemMesh and UDPTransport do). +func NewNode(dc *Doomcom, tr Transport, game Game, build BuildFunc) *Node { + n := &Node{ + id: dc.ConsolePlayer, + numNodes: dc.NumNodes, + tr: tr, + game: game, + build: build, + cmds: make([]map[int]Ticcmd, dc.NumNodes), + hashes: []uint64{game.Hash()}, + pendingReq: make([]int, dc.NumNodes), + exited: make([]bool, dc.NumNodes), + } + for i := range n.cmds { + n.cmds[i] = make(map[int]Ticcmd) + } + for i := range n.pendingReq { + n.pendingReq[i] = -1 + } + return n +} + +// GameTic returns the number of tics this node has simulated. +func (n *Node) GameTic() int { return n.gametic } + +// MakeTic returns the highest local tic this node has produced. +func (n *Node) MakeTic() int { return n.maketic } + +// StateHashes returns the per-tic state-hash history: index k is the state after +// k tics. Two nodes that ran the same tics in lockstep have identical slices. +func (n *Node) StateHashes() []uint64 { return n.hashes } + +// Stats returns a snapshot of this node's counters. +func (n *Node) Stats() Stats { return n.stats } + +// consistancyAt returns uint16 of the state hash at tic max(0, tic-ticLead). +// The caller guarantees that index is present in hashes. +func (n *Node) consistancyAt(tic int) uint16 { + i := tic - ticLead + if i < 0 { + i = 0 + } + return uint16(n.hashes[i]) +} + +// netUpdate produces new local tics (up to ticLead ahead of gametic, bounded by +// target) and sends the recent send-window of local commands to every peer, +// honouring any pending retransmit requests. +func (n *Node) netUpdate() error { + for n.maketic < n.gametic+ticLead && n.maketic < n.target { + cmd := n.build(n.maketic) + cmd.Consistancy = n.consistancyAt(n.maketic) + n.cmds[n.id][n.maketic] = cmd + n.maketic++ + } + + for d := 0; d < n.numNodes; d++ { + if d == n.id { + continue + } + start := n.maketic - windowTics + if start < 0 { + start = 0 + } + if req := n.pendingReq[d]; req >= 0 && req < start { + start = req + } + n.pendingReq[d] = -1 + + num := n.maketic - start + if num <= 0 { + continue + } + if num > BACKUPTICS { + start = n.maketic - BACKUPTICS + num = BACKUPTICS + } + + dd := &Doomdata{Player: uint8(n.id), StartTic: uint8(start)} + dd.Cmds = make([]Ticcmd, num) + for i := 0; i < num; i++ { + dd.Cmds[i] = n.cmds[n.id][start+i] + } + b, err := dd.Encode() + if err != nil { + return err + } + if err := n.tr.Send(d, b); err != nil { + return err + } + n.stats.PacketsSent++ + } + return nil +} + +// pump drains all pending inbound packets, storing received ticcmds and noting +// retransmit requests and exits. Packets that fail to decode are counted as +// dropped and ignored (lossy datagram semantics). +func (n *Node) pump() { + for { + p, ok := n.tr.Recv() + if !ok { + return + } + n.stats.PacketsRecv++ + dd, err := DecodeDoomdata(p.Data) + if err != nil { + n.stats.PacketsDropped++ + continue + } + if dd.Flags&NCMD_RETRANSMIT != 0 { + rf := int(dd.RetransmitFrom) + if n.pendingReq[p.Node] < 0 || rf < n.pendingReq[p.Node] { + n.pendingReq[p.Node] = rf + } + continue + } + if dd.Flags&NCMD_EXIT != 0 { + n.exited[dd.Player] = true + } + pl := int(dd.Player) + if pl < 0 || pl >= n.numNodes { + continue + } + for i := 0; i < int(dd.NumTics); i++ { + t := int(dd.StartTic) + i + if _, have := n.cmds[pl][t]; !have { + n.cmds[pl][t] = dd.Cmds[i] + } + } + } +} + +// tryRun runs every tic for which all players' commands are available, checking +// consistancy before each. On a gap it issues a retransmit request to the +// missing player and stops. It returns a *DesyncError on a consistancy mismatch. +func (n *Node) tryRun() error { + for n.gametic < n.target { + missing := -1 + for p := 0; p < n.numNodes; p++ { + if _, ok := n.cmds[p][n.gametic]; !ok { + missing = p + break + } + } + if missing >= 0 { + n.requestResend(missing, n.gametic) + return nil + } + + want := n.consistancyAt(n.gametic) + cmds := make([]Ticcmd, n.numNodes) + for p := 0; p < n.numNodes; p++ { + c := n.cmds[p][n.gametic] + if c.Consistancy != want { + return &DesyncError{ + Node: n.id, Player: p, Tic: n.gametic, + Got: c.Consistancy, Want: want, + } + } + cmds[p] = c + } + + n.game.Step(cmds) + n.gametic++ + n.hashes = append(n.hashes, n.game.Hash()) + n.stats.TicsRun++ + } + return nil +} + +// requestResend sends an NCMD_RETRANSMIT request to player from, asking it to +// resend starting at tic. +func (n *Node) requestResend(from, tic int) { + req := &Doomdata{ + Flags: NCMD_RETRANSMIT, + RetransmitFrom: uint8(tic), + Player: uint8(n.id), + } + b, err := req.Encode() + if err != nil { + return + } + if err := n.tr.Send(from, b); err == nil { + n.stats.Resends++ + } +} + +// RunGame drives nodes in lockstep until every node has simulated target tics, +// or an error occurs. It returns: +// +// - *DesyncError on a consistancy mismatch, +// - *TimeoutError when a node cannot obtain a peer's tics (lost node), +// - ErrMaxRounds if maxRounds is exhausted without any cleaner diagnosis, +// - a transport error, or nil on success. +// +// The scheduler is deterministic: each round it runs netUpdate, then pump, then +// tryRun for every node in index order, so a given set of nodes, games, build +// functions and (deterministic) transport always produces the same result. +func RunGame(nodes []*Node, target, maxRounds int) error { + for _, nd := range nodes { + nd.target = target + } + + stalled := 0 + for round := 0; round < maxRounds; round++ { + for _, nd := range nodes { + nd.round = round + if err := nd.netUpdate(); err != nil { + return err + } + } + for _, nd := range nodes { + nd.pump() + } + + progressed := false + for _, nd := range nodes { + before := nd.gametic + if err := nd.tryRun(); err != nil { + return err + } + if nd.gametic > before { + progressed = true + } + } + + if allDone(nodes, target) { + return nil + } + if progressed { + stalled = 0 + continue + } + stalled++ + if stalled > timeoutRounds { + if te := diagnoseStall(nodes); te != nil { + return te + } + return ErrMaxRounds + } + } + return ErrMaxRounds +} + +// Simulate is a convenience wrapper around RunGame with a generous round budget. +func Simulate(nodes []*Node, target int) error { + return RunGame(nodes, target, target*16+2048) +} + +func allDone(nodes []*Node, target int) bool { + for _, nd := range nodes { + if nd.gametic < target { + return false + } + } + return true +} + +// diagnoseStall finds a node still short of target and the first player whose +// tic it is missing, so a lost-node timeout can be reported cleanly. +func diagnoseStall(nodes []*Node) *TimeoutError { + for _, nd := range nodes { + if nd.gametic >= nd.target { + continue + } + for p := 0; p < nd.numNodes; p++ { + if _, ok := nd.cmds[p][nd.gametic]; !ok { + return &TimeoutError{Node: nd.id, Player: p, Tic: nd.gametic} + } + } + } + return nil +} + +// Handshake performs deterministic node discovery over the given transports +// (trs[i] belongs to node i). Each node repeatedly broadcasts an NCMD_SETUP +// announcement and collects announcements from its peers; once every node has +// heard from all N participants, each is assigned a Doomcom with the shared +// Config, NumNodes/NumPlayers = N and ConsolePlayer = its own index (node +// indices are the lowest-id-first arbitration). It returns ErrHandshake if it +// does not converge within maxRounds. +func Handshake(trs []Transport, cfg Config, maxRounds int) ([]*Doomcom, error) { + nn := len(trs) + seen := make([]map[int]bool, nn) + for i := range seen { + seen[i] = map[int]bool{i: true} + } + + for round := 0; round < maxRounds; round++ { + for i := 0; i < nn; i++ { + dd := &Doomdata{Flags: NCMD_SETUP, Player: uint8(i)} + b, _ := dd.Encode() + for d := 0; d < nn; d++ { + if d != i { + _ = trs[i].Send(d, b) + } + } + } + for i := 0; i < nn; i++ { + for { + p, ok := trs[i].Recv() + if !ok { + break + } + dd, err := DecodeDoomdata(p.Data) + if err != nil { + continue + } + if dd.Flags&NCMD_SETUP != 0 { + seen[i][int(dd.Player)] = true + } + } + } + if handshakeConverged(seen, nn) { + dcs := make([]*Doomcom, nn) + for i := 0; i < nn; i++ { + dcs[i] = &Doomcom{ + NumNodes: nn, + NumPlayers: nn, + ConsolePlayer: i, + TicDup: cfg.TicDup, + ExtraTics: cfg.ExtraTics, + DeathMatch: cfg.DeathMatch, + } + } + return dcs, nil + } + } + return nil, ErrHandshake +} + +func handshakeConverged(seen []map[int]bool, nn int) bool { + for i := 0; i < nn; i++ { + if len(seen[i]) < nn { + return false + } + } + return true +} diff --git a/netgame/net_test.go b/netgame/net_test.go new file mode 100644 index 0000000..6e9e2e4 --- /dev/null +++ b/netgame/net_test.go @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "encoding/binary" + "errors" + "hash/fnv" + "math/rand" + "testing" +) + +// splitmix64 is a tiny deterministic PRNG used to derive reproducible ticcmds. +func splitmix64(x uint64) uint64 { + x += 0x9E3779B97F4A7C15 + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9 + x = (x ^ (x >> 27)) * 0x94D049BB133111EB + return x ^ (x >> 31) +} + +// detBuild returns a deterministic input source for a given player: identical +// on every node, so all nodes agree on that player's ticcmd for each tic. +func detBuild(player int) BuildFunc { + return func(tic int) Ticcmd { + h := splitmix64(uint64(player)*0x100000001B3 + uint64(tic)) + return Ticcmd{ + ForwardMove: int8(h), + SideMove: int8(h >> 8), + AngleTurn: int16(h >> 16), + Buttons: uint8(h >> 32), + ChatChar: uint8(h >> 40), + } + } +} + +// meshNodes handshakes n nodes over mesh and returns them ready to run. Each +// node uses a fresh HashGame with the same seed and its own deterministic input. +func meshNodes(t *testing.T, n int, mesh *MemMesh, gameFor func(i int) Game) []*Node { + t.Helper() + trs := make([]Transport, n) + for i := 0; i < n; i++ { + trs[i] = mesh.Endpoint(i) + } + dcs, err := Handshake(trs, Config{TicDup: 1}, 64) + if err != nil { + t.Fatalf("handshake: %v", err) + } + // Drain any residual SETUP packets so per-node stats start clean. + for i := 0; i < n; i++ { + for { + if _, ok := trs[i].Recv(); !ok { + break + } + } + } + nodes := make([]*Node, n) + for i := 0; i < n; i++ { + nodes[i] = NewNode(dcs[i], trs[i], gameFor(i), detBuild(i)) + } + return nodes +} + +func defaultGame(int) Game { return NewHashGame(0x5EED) } + +// assertIdentical checks that all nodes share byte-identical per-tic state-hash +// histories (the lockstep determinism oracle). +func assertIdentical(t *testing.T, nodes []*Node, target int) { + t.Helper() + ref := nodes[0].StateHashes() + if len(ref) != target+1 { + t.Fatalf("node 0 ran %d tics, want %d", len(ref)-1, target) + } + for i := 1; i < len(nodes); i++ { + h := nodes[i].StateHashes() + if len(h) != len(ref) { + t.Fatalf("node %d history len %d != %d", i, len(h), len(ref)) + } + for k := range ref { + if h[k] != ref[k] { + t.Fatalf("node %d diverged at tic %d: %#016x != %#016x", i, k, h[k], ref[k]) + } + } + } +} + +func TestLockstepDeterminism(t *testing.T) { + const target = 150 + for _, n := range []int{2, 3, 4} { + mesh := NewMemMesh(n) + nodes := meshNodes(t, n, mesh, defaultGame) + if err := Simulate(nodes, target); err != nil { + t.Fatalf("n=%d: %v", n, err) + } + for _, nd := range nodes { + if nd.GameTic() != target { + t.Fatalf("n=%d node %d reached tic %d, want %d", n, nd.id, nd.GameTic(), target) + } + if nd.MakeTic() != target { + t.Fatalf("n=%d node %d maketic %d, want %d", n, nd.id, nd.MakeTic(), target) + } + } + assertIdentical(t, nodes, target) + } +} + +func TestPacketLossConvergence(t *testing.T) { + const ( + n = 3 + target = 120 + ) + mesh := NewMemMesh(n) + r := rand.New(rand.NewSource(1234)) + mesh.SetDrop(func(from, to, seq int) bool { return r.Float64() < 0.30 }) + nodes := meshNodes(t, n, mesh, defaultGame) + if err := Simulate(nodes, target); err != nil { + t.Fatalf("under 30%% loss: %v", err) + } + assertIdentical(t, nodes, target) + + totalResends := 0 + for _, nd := range nodes { + totalResends += nd.Stats().Resends + } + if totalResends == 0 { + t.Fatal("expected the resend path to be exercised under loss") + } +} + +func TestReorderConvergence(t *testing.T) { + const ( + n = 2 + target = 100 + ) + mesh := NewMemMesh(n) + mesh.SetReorder(99) + r := rand.New(rand.NewSource(7)) + mesh.SetDrop(func(from, to, seq int) bool { return r.Float64() < 0.15 }) + nodes := meshNodes(t, n, mesh, defaultGame) + if err := Simulate(nodes, target); err != nil { + t.Fatalf("under reorder+loss: %v", err) + } + assertIdentical(t, nodes, target) +} + +func TestLostNodeTimeout(t *testing.T) { + const n = 3 + mesh := NewMemMesh(n) + nodes := meshNodes(t, n, mesh, defaultGame) + // After a clean handshake, node 0 goes permanently silent. + mesh.SetDrop(func(from, to, seq int) bool { return from == 0 }) + err := RunGame(nodes, 100, 20000) + var te *TimeoutError + if !errors.As(err, &te) { + t.Fatalf("err = %v, want *TimeoutError", err) + } + if te.Error() == "" { + t.Fatal("empty timeout message") + } +} + +// desyncGame mirrors HashGame but injects a divergence at tic badAt, modelling a +// node whose simulation drifts out of sync with its peers. +type desyncGame struct { + state uint64 + step int + badAt int +} + +func (g *desyncGame) Hash() uint64 { return g.state } + +func (g *desyncGame) Step(cmds []Ticcmd) { + h := fnv.New64a() + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], g.state) + _, _ = h.Write(buf[:]) + for i := range cmds { + b, _ := cmds[i].MarshalBinary() + b[4], b[5] = 0, 0 + _, _ = h.Write(b) + } + g.state = h.Sum64() + if g.step == g.badAt { + g.state ^= 1 // silent corruption + } + g.step++ +} + +func TestDesyncDetection(t *testing.T) { + const ( + n = 3 + target = 60 + ) + mesh := NewMemMesh(n) + nodes := meshNodes(t, n, mesh, func(i int) Game { + if i == 1 { + return &desyncGame{state: 0x5EED, badAt: 10} + } + return NewHashGame(0x5EED) + }) + err := Simulate(nodes, target) + var de *DesyncError + if !errors.As(err, &de) { + t.Fatalf("err = %v, want *DesyncError", err) + } + if de.Player != 1 { + t.Fatalf("desync attributed to player %d, want 1", de.Player) + } + if de.Got == de.Want { + t.Fatalf("desync error with matching consistancy: %+v", de) + } + if de.Error() == "" { + t.Fatal("empty desync message") + } +} + +func TestRunGameMaxRounds(t *testing.T) { + const n = 2 + mesh := NewMemMesh(n) + nodes := meshNodes(t, n, mesh, defaultGame) + if err := RunGame(nodes, 150, 2); !errors.Is(err, ErrMaxRounds) { + t.Fatalf("err = %v, want ErrMaxRounds", err) + } +} + +func TestNetUpdateSendError(t *testing.T) { + const n = 2 + mesh := NewMemMesh(n) + nodes := meshNodes(t, n, mesh, defaultGame) + // Closing one endpoint closes the whole mesh; the next Send must error out + // through netUpdate and RunGame. + if err := nodes[0].tr.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if err := RunGame(nodes, 50, 100); !errors.Is(err, ErrClosed) { + t.Fatalf("err = %v, want ErrClosed", err) + } +} + +func TestHandshakeFailure(t *testing.T) { + const n = 3 + mesh := NewMemMesh(n) + mesh.SetDrop(func(from, to, seq int) bool { return true }) // nothing gets through + trs := make([]Transport, n) + for i := 0; i < n; i++ { + trs[i] = mesh.Endpoint(i) + } + if _, err := Handshake(trs, Config{}, 5); !errors.Is(err, ErrHandshake) { + t.Fatalf("err = %v, want ErrHandshake", err) + } +} + +func TestHandshakeAssignsConsoleplayer(t *testing.T) { + const n = 4 + mesh := NewMemMesh(n) + trs := make([]Transport, n) + for i := 0; i < n; i++ { + trs[i] = mesh.Endpoint(i) + } + dcs, err := Handshake(trs, Config{TicDup: 2, ExtraTics: 1, DeathMatch: 1}, 64) + if err != nil { + t.Fatalf("handshake: %v", err) + } + for i, dc := range dcs { + if dc.ConsolePlayer != i { + t.Fatalf("node %d consoleplayer = %d", i, dc.ConsolePlayer) + } + if dc.NumNodes != n || dc.NumPlayers != n { + t.Fatalf("node %d nodes/players = %d/%d", i, dc.NumNodes, dc.NumPlayers) + } + if dc.TicDup != 2 || dc.ExtraTics != 1 || dc.DeathMatch != 1 { + t.Fatalf("node %d config not propagated: %+v", i, dc) + } + } +} + +func TestNetUpdateWindowCap(t *testing.T) { + mesh := NewMemMesh(2) + dc := &Doomcom{NumNodes: 2, NumPlayers: 2, ConsolePlayer: 0} + nd := NewNode(dc, mesh.Endpoint(0), NewHashGame(0), detBuild(0)) + + // Fabricate a far-ahead node so the send window exceeds BACKUPTICS. + nd.target = 400 + nd.gametic = 300 + for len(nd.hashes) <= nd.gametic { + nd.hashes = append(nd.hashes, uint64(len(nd.hashes))) + } + // Ask to resend from tic 0: start collapses to 0, so num > BACKUPTICS and + // must be capped. + nd.pendingReq[1] = 0 + if err := nd.netUpdate(); err != nil { + t.Fatalf("netUpdate: %v", err) + } + p, ok := mesh.Endpoint(1).Recv() + if !ok { + t.Fatal("expected a packet") + } + dd, err := DecodeDoomdata(p.Data) + if err != nil { + t.Fatalf("decode: %v", err) + } + if int(dd.NumTics) != BACKUPTICS { + t.Fatalf("NumTics = %d, want capped to %d", dd.NumTics, BACKUPTICS) + } + if int(dd.StartTic) != nd.maketic-BACKUPTICS { + t.Fatalf("StartTic = %d, want %d", dd.StartTic, nd.maketic-BACKUPTICS) + } +} + +func TestNetUpdateNothingToSend(t *testing.T) { + mesh := NewMemMesh(2) + dc := &Doomcom{NumNodes: 2, NumPlayers: 2, ConsolePlayer: 0} + nd := NewNode(dc, mesh.Endpoint(0), NewHashGame(0), detBuild(0)) + nd.target = 0 // produce nothing -> num <= 0 -> nothing sent + if err := nd.netUpdate(); err != nil { + t.Fatalf("netUpdate: %v", err) + } + if _, ok := mesh.Endpoint(1).Recv(); ok { + t.Fatal("no packet should be sent when there are no tics") + } +} + +func TestDiagnoseStall(t *testing.T) { + mesh := NewMemMesh(2) + // A node that has reached its target contributes no diagnosis. + done := NewNode(&Doomcom{NumNodes: 1, NumPlayers: 1, ConsolePlayer: 0}, + mesh.Endpoint(0), NewHashGame(0), detBuild(0)) + done.target = 0 + if te := diagnoseStall([]*Node{done}); te != nil { + t.Fatalf("finished node diagnosed as stalled: %v", te) + } + // A node short of target, missing player 0's tic, is diagnosed. + stuck := NewNode(&Doomcom{NumNodes: 2, NumPlayers: 2, ConsolePlayer: 1}, + mesh.Endpoint(1), NewHashGame(0), detBuild(1)) + stuck.target = 5 + te := diagnoseStall([]*Node{stuck}) + if te == nil || te.Player != 0 || te.Tic != 0 { + t.Fatalf("diagnose = %+v, want player 0 tic 0", te) + } +} + +// TestPumpHandlesAllPacketKinds drives pump directly with crafted packets to +// cover the retransmit-request, exit, bad-checksum and out-of-range branches. +func TestPumpHandlesAllPacketKinds(t *testing.T) { + mesh := NewMemMesh(2) + dc := &Doomcom{NumNodes: 2, NumPlayers: 2, ConsolePlayer: 0} + nd := NewNode(dc, mesh.Endpoint(0), NewHashGame(0), detBuild(0)) + peer := mesh.Endpoint(1) + + // Normal tic delivery from player 1. + good := &Doomdata{Player: 1, StartTic: 0, Cmds: []Ticcmd{{ForwardMove: 7}}} + gb, _ := good.Encode() + // Retransmit request. + req := &Doomdata{Flags: NCMD_RETRANSMIT, RetransmitFrom: 5, Player: 1} + rb, _ := req.Encode() + // Exit notice. + exit := &Doomdata{Flags: NCMD_EXIT, Player: 1} + eb, _ := exit.Encode() + // Out-of-range player. + oor := &Doomdata{Player: 9} + ob, _ := oor.Encode() + + for _, b := range [][]byte{gb, rb, eb, ob} { + if err := peer.Send(0, b); err != nil { + t.Fatalf("send: %v", err) + } + } + // A corrupt datagram (bad checksum) must be counted as dropped. + bad := append([]byte(nil), gb...) + bad[len(bad)-1] ^= 0xFF + if err := peer.Send(0, bad); err != nil { + t.Fatalf("send bad: %v", err) + } + + nd.pump() + + if _, ok := nd.cmds[1][0]; !ok { + t.Fatal("normal tic was not stored") + } + if nd.pendingReq[1] != 5 { + t.Fatalf("retransmit request not recorded: %d", nd.pendingReq[1]) + } + if !nd.exited[1] { + t.Fatal("exit flag not set") + } + if nd.stats.PacketsDropped != 1 { + t.Fatalf("PacketsDropped = %d, want 1", nd.stats.PacketsDropped) + } +} diff --git a/netgame/packet.go b/netgame/packet.go new file mode 100644 index 0000000..32571e4 --- /dev/null +++ b/netgame/packet.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "encoding/binary" + "errors" +) + +// Command flags packed into the high bits of the doomdata checksum word, +// matching vanilla d_net.c. The low 28 bits (NCMD_CHECKSUM) hold the running +// checksum; the top 4 bits carry the command flags. +const ( + NCMD_EXIT uint32 = 0x80000000 // player is leaving the game + NCMD_RETRANSMIT uint32 = 0x40000000 // this is a retransmit request + NCMD_SETUP uint32 = 0x20000000 // node discovery / handshake + NCMD_KILL uint32 = 0x10000000 // kill the game + NCMD_CHECKSUM uint32 = 0x0FFFFFFF // mask for the running checksum +) + +// ncmdFlags is the mask of all flag bits (the complement of NCMD_CHECKSUM). +const ncmdFlags = ^NCMD_CHECKSUM + +// checksumSeed is vanilla DOOM's NetbufferChecksum seed. +const checksumSeed uint32 = 0x1234567 + +// Packet errors. +var ( + // ErrShort is returned when a buffer is too small or misaligned to hold a + // valid doomdata packet. + ErrShort = errors.New("netgame: packet too short or misaligned") + // ErrChecksum is returned when a decoded packet's stored checksum does not + // match the recomputed checksum. + ErrChecksum = errors.New("netgame: packet checksum mismatch") + // ErrTicCount is returned when numtics does not agree with the payload + // length, or when more than 255 ticcmds are supplied to Encode. + ErrTicCount = errors.New("netgame: ticcmd count out of range") +) + +// Doomdata is the classic network packet (vanilla doomdata_t). On the wire it +// is a little-endian uint32 checksum/flags word followed by the four header +// bytes (retransmitfrom, starttic, player, numtics) and numtics ticcmds. +// +// The Flags field holds the command bits (NCMD_EXIT/RETRANSMIT/SETUP/KILL); the +// running checksum in the low 28 bits is computed by Encode and verified by +// DecodeDoomdata, so callers never manage it directly. +type Doomdata struct { + Flags uint32 // NCMD_* command bits (checksum bits are ignored here) + RetransmitFrom uint8 // only meaningful with NCMD_RETRANSMIT + StartTic uint8 // tic number of Cmds[0] (low 8 bits, vanilla) + Player uint8 // which player these commands belong to + NumTics uint8 // set from len(Cmds) by Encode + Cmds []Ticcmd +} + +// checksum computes the vanilla running sum over body, treated as little-endian +// uint32 words. body must be a multiple of 4 bytes long (the caller guarantees +// this: 4 header bytes + 8 bytes per ticcmd). The result is not yet masked. +func checksum(body []byte) uint32 { + c := checksumSeed + for i := 0; i+4 <= len(body); i += 4 { + w := binary.LittleEndian.Uint32(body[i : i+4]) + c += w * uint32(i/4+1) + } + return c +} + +// Encode serializes the packet, computing and embedding the checksum exactly as +// vanilla did (running sum over every word from retransmitfrom onward, masked +// with NCMD_CHECKSUM and OR'd with the command flags). NumTics is set from +// len(Cmds). It returns ErrTicCount if there are more than 255 ticcmds. +func (d *Doomdata) Encode() ([]byte, error) { + if len(d.Cmds) > 255 { + return nil, ErrTicCount + } + d.NumTics = uint8(len(d.Cmds)) + + body := make([]byte, 0, 4+TicSize*len(d.Cmds)) + body = append(body, d.RetransmitFrom, d.StartTic, d.Player, d.NumTics) + for i := range d.Cmds { + cb, _ := d.Cmds[i].MarshalBinary() + body = append(body, cb...) + } + + sum := checksum(body) & NCMD_CHECKSUM + word := (d.Flags & ncmdFlags) | sum + + out := make([]byte, 4+len(body)) + binary.LittleEndian.PutUint32(out[0:4], word) + copy(out[4:], body) + return out, nil +} + +// DecodeDoomdata parses a packet produced by Encode. It verifies the checksum +// (returning ErrChecksum on mismatch), rejects short/misaligned buffers +// (ErrShort), and validates numtics against the payload length (ErrTicCount). +func DecodeDoomdata(b []byte) (*Doomdata, error) { + if len(b) < 8 { // 4-byte checksum word + at least the 4 header bytes + return nil, ErrShort + } + body := b[4:] + if len(body) < 4 || (len(body)-4)%TicSize != 0 { + return nil, ErrShort + } + + word := binary.LittleEndian.Uint32(b[0:4]) + stored := word & NCMD_CHECKSUM + if checksum(body)&NCMD_CHECKSUM != stored { + return nil, ErrChecksum + } + + d := &Doomdata{ + Flags: word & ncmdFlags, + RetransmitFrom: body[0], + StartTic: body[1], + Player: body[2], + NumTics: body[3], + } + n := int(d.NumTics) + if 4+TicSize*n != len(body) { + return nil, ErrTicCount + } + d.Cmds = make([]Ticcmd, n) + for i := 0; i < n; i++ { + off := 4 + TicSize*i + if err := d.Cmds[i].UnmarshalBinary(body[off : off+TicSize]); err != nil { + return nil, err + } + } + return d, nil +} diff --git a/netgame/ticcmd.go b/netgame/ticcmd.go new file mode 100644 index 0000000..98bdad2 --- /dev/null +++ b/netgame/ticcmd.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +// Package netgame implements the classic DOOM netgame (multiplayer) protocol +// as a self-contained, testable pure-Go package: the doomcom / doomdata / +// ticcmd lockstep exchange modelled after id Software's d_net.c / i_net.c +// (the chocolate-doom-faithful behaviour). +// +// This is ORIGINAL Go work modelling a wire format — not a line-by-line copy +// of any GPL source — and is therefore licensed BSD-3-Clause. +// +// The three wire types are: +// +// - Ticcmd — one player's input for one tic (vanilla 8-byte layout). +// - Doomdata — a network packet: a checksum/flags word, retransmitfrom, +// starttic, player, numtics, then numtics Ticcmds. +// - Doomcom — the per-node game descriptor (numnodes, numplayers, +// consoleplayer, ticdup, extratics, deathmatch, ...). +// +// A Transport seam abstracts the datagram layer so the deterministic lockstep +// loop (see net.go) runs fully in-process over an in-memory mesh for tests, or +// over real UDP sockets in production — all with CGO disabled. +package netgame + +import ( + "encoding/binary" + "errors" +) + +// TicSize is the vanilla on-the-wire size of a single ticcmd, in bytes. +const TicSize = 8 + +// ErrTicSize is returned when decoding a ticcmd from a buffer whose length is +// not exactly TicSize bytes. +var ErrTicSize = errors.New("netgame: ticcmd buffer must be exactly 8 bytes") + +// Ticcmd is one player's input for one tic. Its serialization is byte-identical +// to the engine's own saveg_write_ticcmd_t (vanilla 8-byte, little-endian): +// +// forwardmove int8 (1) +// sidemove int8 (1) +// angleturn int16 (2, LE) +// consistancy uint16 (2, LE) +// chatchar uint8 (1) +// buttons uint8 (1) +// +// Note the field ORDER on the wire differs from the Go struct field order: +// angleturn precedes consistancy on the wire, matching vanilla. +type Ticcmd struct { + ForwardMove int8 + SideMove int8 + AngleTurn int16 + ChatChar uint8 + Buttons uint8 + Consistancy uint16 +} + +// MarshalBinary encodes the ticcmd into exactly 8 bytes using the vanilla +// little-endian layout. It never returns an error. +func (t Ticcmd) MarshalBinary() ([]byte, error) { + b := make([]byte, TicSize) + b[0] = byte(t.ForwardMove) + b[1] = byte(t.SideMove) + binary.LittleEndian.PutUint16(b[2:4], uint16(t.AngleTurn)) + binary.LittleEndian.PutUint16(b[4:6], t.Consistancy) + b[6] = t.ChatChar + b[7] = t.Buttons + return b, nil +} + +// UnmarshalBinary decodes an 8-byte vanilla ticcmd from b. It returns ErrTicSize +// if len(b) != TicSize. +func (t *Ticcmd) UnmarshalBinary(b []byte) error { + if len(b) != TicSize { + return ErrTicSize + } + t.ForwardMove = int8(b[0]) + t.SideMove = int8(b[1]) + t.AngleTurn = int16(binary.LittleEndian.Uint16(b[2:4])) + t.Consistancy = binary.LittleEndian.Uint16(b[4:6]) + t.ChatChar = b[6] + t.Buttons = b[7] + return nil +} diff --git a/netgame/transport.go b/netgame/transport.go new file mode 100644 index 0000000..701a9ee --- /dev/null +++ b/netgame/transport.go @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "errors" + "math/rand" + "net" + "sync" + "time" +) + +// Transport errors. +var ( + // ErrClosed is returned by a transport whose Close has been called. + ErrClosed = errors.New("netgame: transport closed") + // ErrBadNode is returned when Send targets a node index outside [0,n). + ErrBadNode = errors.New("netgame: destination node out of range") +) + +// Packet is a datagram received from a peer. Node is the sender's node index +// and Data is the raw (already length-delimited by the datagram) packet body. +type Packet struct { + Node int + Data []byte +} + +// Transport is the injectable datagram seam used by the lockstep loop. It is +// deliberately non-blocking: Recv reports whether a packet was available rather +// than blocking, so the loop can poll all nodes deterministically. +type Transport interface { + // Send delivers data to the given peer node index. + Send(node int, data []byte) error + // Recv returns the next queued packet, or ok=false if none is pending. + Recv() (Packet, bool) + // Close releases the transport's resources. + Close() error +} + +// --------------------------------------------------------------------------- +// In-memory mesh transport (for tests, no real sockets). +// --------------------------------------------------------------------------- + +// MemMesh wires N nodes together in-process. Each node gets a Transport from +// Endpoint(i); Send from node i to node j enqueues a copy of the data into +// node j's inbox, tagged with sender i. Optional deterministic packet loss and +// reordering can be installed for resilience testing. +type MemMesh struct { + mu sync.Mutex + n int + queues [][]Packet + drop func(from, to, seq int) bool + reorder bool + rnd *rand.Rand + seq int + closed bool +} + +// NewMemMesh creates a mesh for n nodes. +func NewMemMesh(n int) *MemMesh { + return &MemMesh{n: n, queues: make([][]Packet, n)} +} + +// Endpoint returns the Transport for node id. +func (m *MemMesh) Endpoint(id int) Transport { return &memEndpoint{mesh: m, id: id} } + +// SetDrop installs a delivery filter. Returning true drops the packet in +// transit (the Send still succeeds — datagrams are lossy). from/to are node +// indices and seq is a monotonically increasing per-mesh counter, so the filter +// can be made fully deterministic. +func (m *MemMesh) SetDrop(f func(from, to, seq int) bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.drop = f +} + +// SetReorder enables deterministic reordering: delivered packets are inserted at +// a pseudo-random position in the receiver's queue, seeded by seed. +func (m *MemMesh) SetReorder(seed int64) { + m.mu.Lock() + defer m.mu.Unlock() + m.reorder = true + m.rnd = rand.New(rand.NewSource(seed)) +} + +type memEndpoint struct { + mesh *MemMesh + id int +} + +func (e *memEndpoint) Send(node int, data []byte) error { + m := e.mesh + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return ErrClosed + } + if node < 0 || node >= m.n { + return ErrBadNode + } + seq := m.seq + m.seq++ + if m.drop != nil && m.drop(e.id, node, seq) { + return nil // silently lost in transit + } + cp := make([]byte, len(data)) + copy(cp, data) + pkt := Packet{Node: e.id, Data: cp} + + q := m.queues[node] + if m.reorder && len(q) > 0 { + idx := m.rnd.Intn(len(q) + 1) + q = append(q, Packet{}) + copy(q[idx+1:], q[idx:]) + q[idx] = pkt + } else { + q = append(q, pkt) + } + m.queues[node] = q + return nil +} + +func (e *memEndpoint) Recv() (Packet, bool) { + m := e.mesh + m.mu.Lock() + defer m.mu.Unlock() + q := m.queues[e.id] + if len(q) == 0 { + return Packet{}, false + } + p := q[0] + m.queues[e.id] = q[1:] + return p, true +} + +func (e *memEndpoint) Close() error { + m := e.mesh + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + return nil +} + +// --------------------------------------------------------------------------- +// UDP transport (real sockets, pure-Go, CGO=0). +// --------------------------------------------------------------------------- + +// UDPTransport is a real-socket Transport backed by a net.UDPConn. It maps peer +// node indices to UDP addresses and back, so Recv can report which node a +// datagram came from. It performs no CGO and works on every Go target. +type UDPTransport struct { + conn *net.UDPConn + addrs []*net.UDPAddr // node index -> peer address + rev map[string]int // peer address string -> node index + buf []byte + pollTimeout time.Duration // max time a single Recv poll may block +} + +// defaultPollTimeout bounds how long a UDP Recv poll blocks. It is deliberately +// small so the lockstep loop stays responsive, but non-zero: a zero/past read +// deadline can return "i/o timeout" even when a datagram is already buffered. +const defaultPollTimeout = 2 * time.Millisecond + +// NewUDPTransport builds a transport over an already-bound conn. addrs maps each +// peer node index to its UDP address (the local node's own slot may be nil). +func NewUDPTransport(conn *net.UDPConn, addrs []*net.UDPAddr) *UDPTransport { + rev := make(map[string]int, len(addrs)) + for i, a := range addrs { + if a != nil { + rev[a.String()] = i + } + } + return &UDPTransport{ + conn: conn, + addrs: addrs, + rev: rev, + buf: make([]byte, 2048), + pollTimeout: defaultPollTimeout, + } +} + +// SetPollTimeout adjusts how long a single Recv may block waiting for a datagram. +func (u *UDPTransport) SetPollTimeout(d time.Duration) { u.pollTimeout = d } + +// Send writes data to the peer node's UDP address. +func (u *UDPTransport) Send(node int, data []byte) error { + if node < 0 || node >= len(u.addrs) || u.addrs[node] == nil { + return ErrBadNode + } + _, err := u.conn.WriteToUDP(data, u.addrs[node]) + return err +} + +// Recv performs a non-blocking read. If no datagram is pending it returns +// ok=false. Datagrams from unknown addresses are reported with Node=-1. +func (u *UDPTransport) Recv() (Packet, bool) { + _ = u.conn.SetReadDeadline(time.Now().Add(u.pollTimeout)) + n, addr, err := u.conn.ReadFromUDP(u.buf) + if err != nil { + return Packet{}, false + } + node := -1 + if idx, ok := u.rev[addr.String()]; ok { + node = idx + } + data := make([]byte, n) + copy(data, u.buf[:n]) + return Packet{Node: node, Data: data}, true +} + +// Close closes the underlying UDP connection. +func (u *UDPTransport) Close() error { return u.conn.Close() } diff --git a/netgame/transport_test.go b/netgame/transport_test.go new file mode 100644 index 0000000..a346b0d --- /dev/null +++ b/netgame/transport_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "errors" + "net" + "testing" + "time" +) + +func TestMemMeshBasic(t *testing.T) { + m := NewMemMesh(3) + a := m.Endpoint(0) + b := m.Endpoint(1) + + if _, ok := b.Recv(); ok { + t.Fatal("expected empty inbox") + } + if err := a.Send(1, []byte("hello")); err != nil { + t.Fatalf("Send: %v", err) + } + p, ok := b.Recv() + if !ok { + t.Fatal("expected a packet") + } + if p.Node != 0 || string(p.Data) != "hello" { + t.Fatalf("got %+v", p) + } + if _, ok := b.Recv(); ok { + t.Fatal("inbox should be drained") + } +} + +func TestMemMeshErrors(t *testing.T) { + m := NewMemMesh(2) + a := m.Endpoint(0) + if err := a.Send(5, nil); !errors.Is(err, ErrBadNode) { + t.Fatalf("bad node err = %v", err) + } + if err := a.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := a.Send(1, nil); !errors.Is(err, ErrClosed) { + t.Fatalf("closed err = %v", err) + } +} + +func TestMemMeshDrop(t *testing.T) { + m := NewMemMesh(2) + m.SetDrop(func(from, to, seq int) bool { return true }) // drop everything + a := m.Endpoint(0) + b := m.Endpoint(1) + if err := a.Send(1, []byte("x")); err != nil { + t.Fatalf("Send: %v", err) + } + if _, ok := b.Recv(); ok { + t.Fatal("packet should have been dropped") + } +} + +func TestMemMeshReorder(t *testing.T) { + m := NewMemMesh(2) + m.SetReorder(1) + a := m.Endpoint(0) + b := m.Endpoint(1) + const n = 20 + for i := 0; i < n; i++ { + if err := a.Send(1, []byte{byte(i)}); err != nil { + t.Fatalf("Send: %v", err) + } + } + seen := make([]bool, n) + ordered := true + prev := -1 + for { + p, ok := b.Recv() + if !ok { + break + } + v := int(p.Data[0]) + seen[v] = true + if v < prev { + ordered = false + } + prev = v + } + for i := range seen { + if !seen[i] { + t.Fatalf("packet %d lost during reorder", i) + } + } + if ordered { + t.Fatal("expected reordering, got in-order delivery") + } +} + +func TestUDPTransport(t *testing.T) { + la, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("resolve: %v", err) + } + c0, err := net.ListenUDP("udp", la) + if err != nil { + t.Fatalf("listen c0: %v", err) + } + c1, err := net.ListenUDP("udp", la) + if err != nil { + t.Fatalf("listen c1: %v", err) + } + a0 := c0.LocalAddr().(*net.UDPAddr) + a1 := c1.LocalAddr().(*net.UDPAddr) + + t0 := NewUDPTransport(c0, []*net.UDPAddr{nil, a1}) + t1 := NewUDPTransport(c1, []*net.UDPAddr{a0, nil}) + t1.SetPollTimeout(3 * time.Millisecond) + + msg := []byte("doomdata") + if err := t0.Send(1, msg); err != nil { + t.Fatalf("Send: %v", err) + } + got, ok := recvRetry(t1) + if !ok { + t.Fatal("no datagram received") + } + if got.Node != 0 || string(got.Data) != "doomdata" { + t.Fatalf("got %+v", got) + } + + // Send to an out-of-range / nil-address node. + if err := t0.Send(9, msg); !errors.Is(err, ErrBadNode) { + t.Fatalf("bad node: %v", err) + } + if err := t0.Send(0, msg); !errors.Is(err, ErrBadNode) { + t.Fatalf("nil address node: %v", err) + } + + // Datagram from an unknown source maps to Node = -1. + c2, err := net.ListenUDP("udp", la) + if err != nil { + t.Fatalf("listen c2: %v", err) + } + if _, err := c2.WriteToUDP([]byte("stranger"), a1); err != nil { + t.Fatalf("c2 write: %v", err) + } + unk, ok := recvRetry(t1) + if !ok { + t.Fatal("no datagram from stranger") + } + if unk.Node != -1 { + t.Fatalf("unknown source Node = %d, want -1", unk.Node) + } + _ = c2.Close() + + // Recv on a closed conn returns ok=false; Send on a closed conn errors. + if err := t1.Close(); err != nil { + t.Fatalf("close t1: %v", err) + } + if _, ok := t1.Recv(); ok { + t.Fatal("Recv after close should report no packet") + } + if err := t0.Close(); err != nil { + t.Fatalf("close t0: %v", err) + } + if err := t0.Send(1, msg); err == nil { + t.Fatal("Send after close should error") + } +} + +// recvRetry polls a transport briefly, since a real UDP datagram may not be in +// the socket buffer the instant after Send returns. +func recvRetry(tr Transport) (Packet, bool) { + for i := 0; i < 200; i++ { + if p, ok := tr.Recv(); ok { + return p, true + } + time.Sleep(time.Millisecond) + } + return Packet{}, false +} diff --git a/netgame/wire_test.go b/netgame/wire_test.go new file mode 100644 index 0000000..0ae4760 --- /dev/null +++ b/netgame/wire_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) the go-doom/engine authors. +// Classic DOOM netgame (doomcom/ticcmd lockstep) — pure-Go, CGO=0. + +package netgame + +import ( + "bytes" + "encoding/binary" + "errors" + "testing" +) + +// TestTiccmdByteFaithful is the byte-faithful oracle: a known ticcmd must +// marshal to the exact vanilla 8-byte little-endian layout. +func TestTiccmdByteFaithful(t *testing.T) { + tc := Ticcmd{ + ForwardMove: 10, + SideMove: -1, + AngleTurn: 0x1234, + ChatChar: 0x7F, + Buttons: 0x80, + Consistancy: 0xBEEF, + } + want := []byte{0x0A, 0xFF, 0x34, 0x12, 0xEF, 0xBE, 0x7F, 0x80} + got, err := tc.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("ticcmd bytes = % x, want % x", got, want) + } +} + +func TestTiccmdRoundTrip(t *testing.T) { + cases := []Ticcmd{ + {}, + {ForwardMove: 127, SideMove: -128, AngleTurn: -1, ChatChar: 255, Buttons: 1, Consistancy: 65535}, + {ForwardMove: -50, SideMove: 50, AngleTurn: 12345, ChatChar: 'a', Buttons: 0x0F, Consistancy: 0x0102}, + } + for i, in := range cases { + b, err := in.MarshalBinary() + if err != nil { + t.Fatalf("case %d marshal: %v", i, err) + } + if len(b) != TicSize { + t.Fatalf("case %d len = %d, want %d", i, len(b), TicSize) + } + var out Ticcmd + if err := out.UnmarshalBinary(b); err != nil { + t.Fatalf("case %d unmarshal: %v", i, err) + } + if out != in { + t.Fatalf("case %d round-trip: got %+v want %+v", i, out, in) + } + } +} + +func TestTiccmdUnmarshalBadSize(t *testing.T) { + var tc Ticcmd + for _, n := range []int{0, 7, 9} { + if err := tc.UnmarshalBinary(make([]byte, n)); !errors.Is(err, ErrTicSize) { + t.Fatalf("len %d: err = %v, want ErrTicSize", n, err) + } + } +} + +func TestDoomdataRoundTrip(t *testing.T) { + in := &Doomdata{ + Flags: NCMD_EXIT, + RetransmitFrom: 3, + StartTic: 17, + Player: 2, + Cmds: []Ticcmd{ + {ForwardMove: 1, SideMove: 2, AngleTurn: 3, Consistancy: 4}, + {ForwardMove: -5, SideMove: -6, AngleTurn: -7, Buttons: 8, Consistancy: 9}, + }, + } + b, err := in.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + if in.NumTics != 2 { + t.Fatalf("Encode should set NumTics=2, got %d", in.NumTics) + } + out, err := DecodeDoomdata(b) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if out.Flags != NCMD_EXIT || out.RetransmitFrom != 3 || out.StartTic != 17 || + out.Player != 2 || out.NumTics != 2 { + t.Fatalf("header mismatch: %+v", out) + } + for i := range in.Cmds { + if out.Cmds[i] != in.Cmds[i] { + t.Fatalf("cmd %d mismatch: got %+v want %+v", i, out.Cmds[i], in.Cmds[i]) + } + } +} + +func TestDoomdataEmpty(t *testing.T) { + in := &Doomdata{Flags: NCMD_RETRANSMIT, RetransmitFrom: 42} + b, err := in.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + out, err := DecodeDoomdata(b) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if out.Flags != NCMD_RETRANSMIT || out.RetransmitFrom != 42 || len(out.Cmds) != 0 { + t.Fatalf("empty round-trip wrong: %+v", out) + } +} + +func TestDoomdataChecksumCorruption(t *testing.T) { + in := &Doomdata{StartTic: 5, Player: 1, Cmds: []Ticcmd{{ForwardMove: 9}}} + b, err := in.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + // Corrupt a payload byte (a cmd byte) without fixing the checksum. + b[len(b)-1] ^= 0xFF + if _, err := DecodeDoomdata(b); !errors.Is(err, ErrChecksum) { + t.Fatalf("corrupted packet err = %v, want ErrChecksum", err) + } +} + +func TestDoomdataDecodeShortAndMisaligned(t *testing.T) { + if _, err := DecodeDoomdata(make([]byte, 7)); !errors.Is(err, ErrShort) { + t.Fatalf("short: %v, want ErrShort", err) + } + // 4-byte header word + 5-byte body: body-4 not a multiple of 8. + if _, err := DecodeDoomdata(make([]byte, 9)); !errors.Is(err, ErrShort) { + t.Fatalf("misaligned: %v, want ErrShort", err) + } +} + +func TestDoomdataDecodeTicCountMismatch(t *testing.T) { + // Craft a buffer whose payload holds two cmds worth of bytes but whose + // numtics field claims 1, with a VALID checksum, to reach ErrTicCount. + body := make([]byte, 4+2*TicSize) + body[0] = 0 // retransmitfrom + body[1] = 0 // starttic + body[2] = 0 // player + body[3] = 1 // numtics = 1 (lie: payload actually holds 2 cmds) + word := checksum(body) & NCMD_CHECKSUM + buf := make([]byte, 4+len(body)) + binary.LittleEndian.PutUint32(buf[0:4], word) + copy(buf[4:], body) + if _, err := DecodeDoomdata(buf); !errors.Is(err, ErrTicCount) { + t.Fatalf("err = %v, want ErrTicCount", err) + } +} + +func TestDoomdataEncodeTooManyTics(t *testing.T) { + in := &Doomdata{Cmds: make([]Ticcmd, 256)} + if _, err := in.Encode(); !errors.Is(err, ErrTicCount) { + t.Fatalf("err = %v, want ErrTicCount", err) + } +} From 8f55877918bc2ac46b7a0744be2787dd27d84cb0 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 27 Jul 2026 17:10:44 +0200 Subject: [PATCH 3/3] ci: make music tests cwd-independent + anchor deadcode roots Two CI-only fixes surfaced by PR #3's first run: 1. 6-arch QEMU jobs run the `go test -c` binaries from the module root, not the package dir, so relative "testdata/..." reads failed (and the module root has its own unrelated testdata/). Each fixture-reading music package now has a test-only init() that locates the go.mod ancestor and chdir's into its own directory, so reads resolve under both native `go test` (cwd = package dir) and the QEMU harness (cwd = repo root). The differential oracles now run on all six arches, not just amd64/arm64. 2. The go.yml `deadcode` gate flagged the new music/netgame code: it is a library consumed via runtime func-pointer indirection (the engine's music_module) and by the cgo frontends (excluded from deadcode), so the curated headless roots never reach it statically. Add the pure-Go music/... and netgame packages as their own -test roots (their tests anchor reachability) plus the root engine package (.), whose new music_opl_test.go exercises the OPL bridge / ReadMusicPCM. deadcode is clean again. Verified locally: music tests pass when run from the repo root (QEMU equivalent); `deadcode -test . ./example/webserver ./cmd/harvest-reference ./embedwad ./music/... ./netgame` reports nothing; engine builds + vets on 386 and all six 64-bit arches; the engine runs to completion with music active on the real shareware WAD (MUS + DMX GENMIDI), and oplplayer renders non-silent PCM from a real shareware MUS lump. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/go.yml | 9 +++++- music/genmidi/pathfix_test.go | 34 ++++++++++++++++++++ music/midi/pathfix_test.go | 34 ++++++++++++++++++++ music/mus/pathfix_test.go | 34 ++++++++++++++++++++ music/opl/pathfix_test.go | 34 ++++++++++++++++++++ music/oplplayer/pathfix_test.go | 34 ++++++++++++++++++++ music_opl_test.go | 55 +++++++++++++++++++++++++++++++++ 7 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 music/genmidi/pathfix_test.go create mode 100644 music/midi/pathfix_test.go create mode 100644 music/mus/pathfix_test.go create mode 100644 music/opl/pathfix_test.go create mode 100644 music/oplplayer/pathfix_test.go create mode 100644 music_opl_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ebf698c..b9be38c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -51,8 +51,15 @@ jobs: # those roots plus -test so deadcode builds the true reachability # graph. The cgo SDL/ebitengine demos are deliberately excluded here # (they don't build without SDL); they are covered by "Build example". + # + # The pure-Go music (music/...) and netgame packages are libraries + # consumed via runtime func-pointer indirection (the engine's + # music_module) and by the cgo frontends (excluded above), so they are + # added as their own -test roots -- their comprehensive package tests + # anchor the true reachability graph -- alongside the root engine + # package (.) whose test exercises the OPL music bridge / ReadMusicPCM. run: | - DEAD=$(deadcode -test ./example/webserver ./cmd/harvest-reference ./embedwad) + DEAD=$(deadcode -test . ./example/webserver ./cmd/harvest-reference ./embedwad ./music/... ./netgame) if [ -n "$DEAD" ]; then echo "deadcode found the following unused code:" echo "$DEAD" diff --git a/music/genmidi/pathfix_test.go b/music/genmidi/pathfix_test.go new file mode 100644 index 0000000..808f499 --- /dev/null +++ b/music/genmidi/pathfix_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// Resolve testdata regardless of the working directory. Native `go test` runs +// with cwd = this package dir, but the 6-arch QEMU CI harness runs the +// `go test -c` binary from the module root, so relative "testdata/..." paths +// would not resolve there (and the module root has its own unrelated testdata/ +// dir). Locate the module root (the go.mod ancestor) and chdir into this +// package's directory, so every relative fixture read works on all arches. + +package genmidi + +import ( + "os" + "path/filepath" +) + +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + _ = os.Chdir(filepath.Join(dir, "music/genmidi")) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // no go.mod found; leave cwd as-is + } + dir = parent + } +} diff --git a/music/midi/pathfix_test.go b/music/midi/pathfix_test.go new file mode 100644 index 0000000..558a180 --- /dev/null +++ b/music/midi/pathfix_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// Resolve testdata regardless of the working directory. Native `go test` runs +// with cwd = this package dir, but the 6-arch QEMU CI harness runs the +// `go test -c` binary from the module root, so relative "testdata/..." paths +// would not resolve there (and the module root has its own unrelated testdata/ +// dir). Locate the module root (the go.mod ancestor) and chdir into this +// package's directory, so every relative fixture read works on all arches. + +package midi + +import ( + "os" + "path/filepath" +) + +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + _ = os.Chdir(filepath.Join(dir, "music/midi")) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // no go.mod found; leave cwd as-is + } + dir = parent + } +} diff --git a/music/mus/pathfix_test.go b/music/mus/pathfix_test.go new file mode 100644 index 0000000..13e99b2 --- /dev/null +++ b/music/mus/pathfix_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// Resolve testdata regardless of the working directory. Native `go test` runs +// with cwd = this package dir, but the 6-arch QEMU CI harness runs the +// `go test -c` binary from the module root, so relative "testdata/..." paths +// would not resolve there (and the module root has its own unrelated testdata/ +// dir). Locate the module root (the go.mod ancestor) and chdir into this +// package's directory, so every relative fixture read works on all arches. + +package mus + +import ( + "os" + "path/filepath" +) + +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + _ = os.Chdir(filepath.Join(dir, "music/mus")) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // no go.mod found; leave cwd as-is + } + dir = parent + } +} diff --git a/music/opl/pathfix_test.go b/music/opl/pathfix_test.go new file mode 100644 index 0000000..05ed495 --- /dev/null +++ b/music/opl/pathfix_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// Resolve testdata regardless of the working directory. Native `go test` runs +// with cwd = this package dir, but the 6-arch QEMU CI harness runs the +// `go test -c` binary from the module root, so relative "testdata/..." paths +// would not resolve there (and the module root has its own unrelated testdata/ +// dir). Locate the module root (the go.mod ancestor) and chdir into this +// package's directory, so every relative fixture read works on all arches. + +package opl + +import ( + "os" + "path/filepath" +) + +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + _ = os.Chdir(filepath.Join(dir, "music/opl")) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // no go.mod found; leave cwd as-is + } + dir = parent + } +} diff --git a/music/oplplayer/pathfix_test.go b/music/oplplayer/pathfix_test.go new file mode 100644 index 0000000..68f4a85 --- /dev/null +++ b/music/oplplayer/pathfix_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. +// +// Resolve testdata regardless of the working directory. Native `go test` runs +// with cwd = this package dir, but the 6-arch QEMU CI harness runs the +// `go test -c` binary from the module root, so relative "testdata/..." paths +// would not resolve there (and the module root has its own unrelated testdata/ +// dir). Locate the module root (the go.mod ancestor) and chdir into this +// package's directory, so every relative fixture read works on all arches. + +package oplplayer + +import ( + "os" + "path/filepath" +) + +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + _ = os.Chdir(filepath.Join(dir, "music/oplplayer")) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // no go.mod found; leave cwd as-is + } + dir = parent + } +} diff --git a/music_opl_test.go b/music_opl_test.go new file mode 100644 index 0000000..92013ba --- /dev/null +++ b/music_opl_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) the go-doom/engine authors. + +package gore + +import "testing" + +// TestOPLMusicModuleWiring exercises the OPL music bridge without a loaded WAD. +// With no GENMIDI lump available, FInit installs no player, so every module +// entry point takes its nil-player path and ReadMusicPCM returns silence. This +// verifies the wiring is panic-free and anchors the bridge as reachable code. +func TestOPLMusicModuleWiring(t *testing.T) { + // installMusicModule is set from music_opl.go's init(). + if installMusicModule == nil { + t.Fatal("installMusicModule hook not registered") + } + installMusicModule() + if music_module == nil { + t.Fatal("music_module not installed") + } + + // No GENMIDI lump is loaded in this bare test, so FInit installs no player. + music_module.FInit() + music_module.FSetMusicVolume(64) + music_module.FPauseMusic() + music_module.FResumeMusic() + if h := music_module.FRegisterSong([]byte("not a song")); h != 0 { + t.Errorf("FRegisterSong with no player: handle=%d, want 0", h) + } + if r := music_module.FPlaySong(0, 1); r != 0 { + t.Errorf("FPlaySong with no player: r=%d, want 0", r) + } + music_module.FStopSong() + music_module.FUnRegisterSong(0) + music_module.FShutdown() + + // ReadMusicPCM returns silence (all zero) and len/2 frames when idle. + buf := make([]int16, 64) + for i := range buf { + buf[i] = 123 + } + frames := ReadMusicPCM(buf) + if frames != len(buf)/2 { + t.Errorf("ReadMusicPCM frames=%d, want %d", frames, len(buf)/2) + } + for i, v := range buf { + if v != 0 { + t.Fatalf("ReadMusicPCM not silent at %d: %d", i, v) + } + } + + if MusicSampleRate != 44100 { + t.Errorf("MusicSampleRate=%d, want 44100", MusicSampleRate) + } +}