Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 9 additions & 1 deletion doom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

//
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 19 additions & 0 deletions music/TESTDATA.md
Original file line number Diff line number Diff line change
@@ -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`).
155 changes: 155 additions & 0 deletions music/genmidi/genmidi.go
Original file line number Diff line number Diff line change
@@ -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],
}
}
Loading
Loading