Skip to content

Commit 6a751c5

Browse files
tannevaledclaude
andauthored
Music (OPL2/OPL3 MUS+MIDI) and multiplayer (netgame lockstep) (#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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5a5d9fa commit 6a751c5

51 files changed

Lines changed: 9920 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/go.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,15 @@ jobs:
5151
# those roots plus -test so deadcode builds the true reachability
5252
# graph. The cgo SDL/ebitengine demos are deliberately excluded here
5353
# (they don't build without SDL); they are covered by "Build example".
54+
#
55+
# The pure-Go music (music/...) and netgame packages are libraries
56+
# consumed via runtime func-pointer indirection (the engine's
57+
# music_module) and by the cgo frontends (excluded above), so they are
58+
# added as their own -test roots -- their comprehensive package tests
59+
# anchor the true reachability graph -- alongside the root engine
60+
# package (.) whose test exercises the OPL music bridge / ReadMusicPCM.
5461
run: |
55-
DEAD=$(deadcode -test ./example/webserver ./cmd/harvest-reference ./embedwad)
62+
DEAD=$(deadcode -test . ./example/webserver ./cmd/harvest-reference ./embedwad ./music/... ./netgame)
5663
if [ -n "$DEAD" ]; then
5764
echo "deadcode found the following unused code:"
5865
echo "$DEAD"

README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,38 @@ examples are kept verbatim, so we can rebase against upstream cleanly.
8585
| Cross-compiles linux/arm64 | yes (CGO=0) |
8686
| Runs shareware DOOM1.WAD | yes (engine ticks; verified via TestMenus harness) |
8787
| TamaGo backend wired | scaffold only; real drivers land in follow-up sprint |
88-
| Music (MUS -> MIDI) | out of scope for sprint 1 |
89-
| Multiplayer | out of scope |
88+
| Music (MUS/MIDI + OPL2/OPL3 synth)| yes -- pure-Go OPL path (see `music/`) |
89+
| Multiplayer (netgame lockstep) | yes -- protocol + lockstep (see `netgame/`)|
90+
91+
## Music and multiplayer (pure Go, CGO=0)
92+
93+
The chocolate-doom-faithful music and netgame paths ship as standalone,
94+
differentially-tested packages, all CGO-free and building on the six 64-bit
95+
arches:
96+
97+
| Package | Role |
98+
|--------------------|-------------------------------------------------------------|
99+
| `music/mus` | DMX MUS (`D_*` lumps) parse + `mus2mid` MUS->MIDI conversion |
100+
| `music/midi` | Standard MIDI File parser (port of `midifile.c`) |
101+
| `music/genmidi` | `GENMIDI` lump (the DMX OPL instrument bank) parser |
102+
| `music/opl` | Nuked OPL3 (Yamaha YMF262) emulator -> PCM |
103+
| `music/oplplayer` | MIDI + GENMIDI -> OPL register writes -> PCM (`i_oplmusic`) |
104+
| `netgame` | classic doomcom/ticcmd lockstep protocol + transport seam |
105+
106+
The engine installs the OPL synth as its `music_module` (see `music_opl.go`);
107+
the host frontend pulls synthesised stereo PCM through `gore.ReadMusicPCM`.
108+
109+
**Differential oracles.** `music/mus` conversion is asserted **byte-identical**
110+
to chocolate-doom's `mus2mid.c` (compiled as an oracle over synthetic MUS
111+
vectors). `music/opl` is validated **bit-exact** against a Nuked OPL3 C
112+
register->PCM trace. `music/oplplayer` is checked against a captured
113+
`i_oplmusic.c` **register-write trace** for `D_INTRO`. `netgame` runs two, three
114+
and four in-process engines over an in-memory transport and asserts they stay in
115+
**lockstep with byte-identical per-tic state hashes**; ticcmd serialisation is
116+
byte-faithful to the engine's own vanilla layout.
117+
118+
Music and multiplayer do not perturb the frame/audio determinism oracles: the
119+
`oracle/` frames and audio-event log remain byte-equal after wiring.
90120

91121
## Quick smoke test (host)
92122

doom.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17340,8 +17340,16 @@ func initSfxModule(use_sfx_prefix boolean) {
1734017340
}
1734117341

1734217342
// Initialize music according to snd_musicdevice.
17343+
//
17344+
// The music module itself lives in music_bridge.go (pure-Go OPL2/OPL3 synth
17345+
// driving the DMX GENMIDI bank, matching chocolate-doom's OPL path). It is
17346+
// registered through installMusicModule so this transpiled entry point keeps
17347+
// a one-line hook and the synth stays out of the generated blob.
1734317348

1734417349
func initMusicModule() {
17350+
if installMusicModule != nil {
17351+
installMusicModule()
17352+
}
1734517353
}
1734617354

1734717355
//
@@ -17503,7 +17511,7 @@ func i_ResumeSong() {
1750317511

1750417512
func i_RegisterSong(data []byte) uintptr {
1750517513
if music_module != nil {
17506-
music_module.FRegisterSong(data)
17514+
return music_module.FRegisterSong(data)
1750717515
}
1750817516
return 0
1750917517
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/go-doom/engine
22

3-
go 1.26.0
3+
go 1.26.4
44

55
require (
66
github.com/hajimehoshi/ebiten/v2 v2.9.9

music/TESTDATA.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Test-data provenance and licensing
2+
3+
The music packages ship small binary fixtures under `*/testdata/`. Their
4+
provenance and license:
5+
6+
| File(s) | Origin | License |
7+
|-------------------------------------------|------------------------------------------------------------------------|---------|
8+
| `mus/testdata/*.mus` | Hand-authored synthetic MUS vectors (this project) | BSD-3-Clause |
9+
| `mus/testdata/*.mid` | Mechanical output of chocolate-doom `mus2mid.c` on the `.mus` vectors (the differential oracle) | derived / functional |
10+
| `opl/testdata/*.pcm` | PCM captured from the Nuked OPL3 C emulator over a synthetic register script | derived / functional |
11+
| `genmidi/testdata/GENMIDI.lmp` | `GENMIDI` lump from **Freedoom Phase 1** (`freedoom1.wad`) | BSD-3-Clause equivalent (Freedoom) |
12+
| `midi/testdata/D_INTRO.lmp` | `D_INTRO` music lump from **Freedoom Phase 1** (`freedoom1.wad`) | BSD-3-Clause equivalent (Freedoom) |
13+
| `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 |
14+
15+
The `GENMIDI.lmp` and `D_INTRO.lmp` fixtures come from
16+
[Freedoom](https://freedoom.github.io/), a free/libre content replacement
17+
released under a BSD-3-Clause-equivalent license, so they are freely
18+
redistributable. No copyrighted id Software WAD data is committed; the engine's
19+
own WAD blob remains git-ignored (see `embedwad/.gitignore`).

music/genmidi/genmidi.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
// Go port of chocolate-doom midifile.c / GENMIDI handling (Simon Howard et al.).
3+
// Ported for the go-doom/engine authors.
4+
5+
// Package genmidi parses the DMX GENMIDI lump, the OPL instrument bank used by
6+
// DOOM's Adlib/OPL music playback. The layout is a faithful port of the
7+
// genmidi_instr_t table read by chocolate-doom's i_oplmusic.c
8+
// LoadInstrumentTable.
9+
package genmidi
10+
11+
import (
12+
"encoding/binary"
13+
"errors"
14+
)
15+
16+
// Header is the 8-byte GENMIDI magic ("#OPL_II#").
17+
const Header = "#OPL_II#"
18+
19+
// Instrument counts, matching i_oplmusic.c.
20+
const (
21+
NumInstruments = 128 // GENMIDI_NUM_INSTRS: melodic instruments
22+
NumPercussion = 47 // GENMIDI_NUM_PERCUSSION
23+
// NumEntries is the total number of instrument entries in the lump, the
24+
// melodic instruments followed by the percussion instruments.
25+
NumEntries = NumInstruments + NumPercussion // 175
26+
)
27+
28+
// Instrument flag bits (genmidi_instr_t.flags).
29+
const (
30+
FlagFixed = 0x0001 // fixed pitch
31+
Flag2Voice = 0x0004 // double voice (OPL3)
32+
)
33+
34+
// Byte sizes of the packed on-disk structures.
35+
const (
36+
operatorSize = 6 // genmidi_op_t
37+
voiceSize = operatorSize + 1 + operatorSize + 1 + 2 // genmidi_voice_t = 16
38+
instrumentSize = 2 + 1 + 1 + 2*voiceSize // genmidi_instr_t = 36
39+
nameSize = 32
40+
)
41+
42+
// Operator holds the six per-operator bytes of a genmidi_op_t.
43+
type Operator struct {
44+
TremoloVibrato uint8 // tremolo
45+
AttackDecay uint8 // attack
46+
SustainRelease uint8 // sustain
47+
Waveform uint8 // waveform
48+
Scale uint8 // scale
49+
Level uint8 // level
50+
}
51+
52+
// Voice is a genmidi_voice_t: a modulator/carrier operator pair plus the
53+
// feedback byte, an unused byte and the signed base note offset.
54+
type Voice struct {
55+
Modulator Operator
56+
Feedback uint8
57+
Carrier Operator
58+
Unused uint8
59+
BaseNoteOffset int16
60+
}
61+
62+
// Instrument is a genmidi_instr_t: flags, tuning and two voices.
63+
type Instrument struct {
64+
Flags uint16
65+
FineTuning uint8
66+
FixedNote uint8
67+
Voices [2]Voice
68+
}
69+
70+
// Bank is a parsed GENMIDI lump. Instruments 0..127 are the melodic
71+
// instruments; 128..174 are the percussion instruments. Names, when present in
72+
// the lump, are the trailing 32-byte name strings in the same order.
73+
type Bank struct {
74+
Instruments [NumEntries]Instrument
75+
// Names holds the trailing 32-byte name strings if the lump includes
76+
// them; it is nil when the lump is truncated to just the instrument data
77+
// (both layouts are accepted, following DMX/i_oplmusic behaviour).
78+
Names [][nameSize]byte
79+
}
80+
81+
// Errors returned by Load.
82+
var (
83+
ErrBadHeader = errors.New("genmidi: missing '#OPL_II#' header")
84+
ErrShort = errors.New("genmidi: lump too short for instrument table")
85+
)
86+
87+
// Load parses a GENMIDI lump. It requires the 8-byte header and the 175
88+
// instrument entries. The trailing name block is parsed when present but is
89+
// optional, matching the two GENMIDI layouts seen in the wild.
90+
func Load(b []byte) (*Bank, error) {
91+
if len(b) < len(Header) {
92+
return nil, ErrShort
93+
}
94+
if string(b[:len(Header)]) != Header {
95+
return nil, ErrBadHeader
96+
}
97+
98+
off := len(Header)
99+
need := off + NumEntries*instrumentSize
100+
if len(b) < need {
101+
return nil, ErrShort
102+
}
103+
104+
bank := &Bank{}
105+
for i := 0; i < NumEntries; i++ {
106+
bank.Instruments[i] = parseInstrument(b[off : off+instrumentSize])
107+
off += instrumentSize
108+
}
109+
110+
// Trailing names are optional. Parse as many complete 32-byte names as the
111+
// lump provides (up to one per entry).
112+
if len(b) >= off+nameSize {
113+
names := make([][nameSize]byte, 0, NumEntries)
114+
for i := 0; i < NumEntries && len(b) >= off+nameSize; i++ {
115+
var n [nameSize]byte
116+
copy(n[:], b[off:off+nameSize])
117+
names = append(names, n)
118+
off += nameSize
119+
}
120+
bank.Names = names
121+
}
122+
123+
return bank, nil
124+
}
125+
126+
func parseInstrument(b []byte) Instrument {
127+
var instr Instrument
128+
instr.Flags = binary.LittleEndian.Uint16(b[0:2])
129+
instr.FineTuning = b[2]
130+
instr.FixedNote = b[3]
131+
instr.Voices[0] = parseVoice(b[4 : 4+voiceSize])
132+
instr.Voices[1] = parseVoice(b[4+voiceSize : 4+2*voiceSize])
133+
return instr
134+
}
135+
136+
func parseVoice(b []byte) Voice {
137+
var v Voice
138+
v.Modulator = parseOperator(b[0:operatorSize])
139+
v.Feedback = b[operatorSize]
140+
v.Carrier = parseOperator(b[operatorSize+1 : 2*operatorSize+1])
141+
v.Unused = b[2*operatorSize+1]
142+
v.BaseNoteOffset = int16(binary.LittleEndian.Uint16(b[2*operatorSize+2 : 2*operatorSize+4]))
143+
return v
144+
}
145+
146+
func parseOperator(b []byte) Operator {
147+
return Operator{
148+
TremoloVibrato: b[0],
149+
AttackDecay: b[1],
150+
SustainRelease: b[2],
151+
Waveform: b[3],
152+
Scale: b[4],
153+
Level: b[5],
154+
}
155+
}

0 commit comments

Comments
 (0)