MML Syntax

From ZeldaHacking Wiki
Jump to navigation Jump to search
  1. Please keep in mind that discussions are ongoing for the future of this syntax / adding bug fixes + features to the audio engine: This information may be outdated until we revisit this page after everything has been standardized.

This page documents writing music for the disassembly's audio engine as MML (Music Macro Language) text instead of hand-writing note/beat/vol/etc. commands directly, and converting it to a ready-to-use channel-data .s file with the mml2wla tool (git clone [1](https://castie.ddns.net/git/mml2wla.git)).

If you aren't already familiar with the Gameboy/GBC sound chip yet, skim this page first. The aforementioned page assumes you know what a channel, a command byte, and a waveform/duty are in this engine. Once you have a converted .s file from this workflow, see Adding Custom Music to actually hook it into a ROM.

Why MML

Hand-writing beat calls works, but it means thinking in raw frame counts and manually managing octave state, tempo math, and repeats. MML is a much more compact, purpose-built text format for exactly this. One line can express a whole phrase with note names, note-length shorthand, and repeat blocks, and a converter resolves all the frame-count/tempo math for you. The disassembly's converter, mml2wla, takes standard mmlgb-dialect .mml source (the same dialect documented at [2](https://github.com/potatoTeto/mmlgb/wiki)) and emits a channel-data .s file in exactly the format described on Music Format. The output could just as well have been hand-written using this engine's own macros.

Important: mmlgb was designed for a different (GBDK-based) sound driver, not this engine. Most of MML translates over cleanly, but a handful of mmlgb features either have no equivalent here and get dropped (with a warning printed to the console explaining why), or only approximate the original effect. Where that happens is called out explicitly below. Check the tool's warnings after every conversion.

Basic syntax

Command Meaning
; Comment. The rest of the line is ignored.
ABCD Select the current channel(s) for the rest of the line. A = pulse 1, B = pulse 2, C = wave, D = noise. Multiple letters together (e.g. AB t120 cdef) apply the same commands to all of them.
c d e f g a b Play a note. Append #/+ for sharp, - for flat.
r Rest. Append a length.
oN Set the current octave.
< / > Shift the current octave down/up by one.
lN Set the default note length used whenever a note/rest/wait omits an explicit one.
vN Set channel volume, 0-15 (0-3 only on the wave channel — see below). Takes effect the next time a note retriggers, not immediately.
tN Set tempo in BPM. Applies to every channel at once.
w Wait. Like r, but doesn't mute the currently playing note.
[...]n Repeat the enclosed block n times. Nestable: [c4[d4e4]5]8 means "c4 followed by d4e4 repeated 5 times", all of that repeated 8 times.
L Mark the loop point, denoting where the song jumps back to once it reaches the end.

Note length

Append a number to a note for its length. Supported denominators are the standard 1/2/3/4/6/8/12/16/24/32 (whole down through thirty-second, including triplets. See the full table in the upstream mmlgb docs). Add ./.. for dotted/double-dotted. Use =N for an exact frame count instead (e.g. c#=4). Use ^ to tie note lengths together (c4^4 == c2).

Macros

Macro Channels Meaning Converts to
@wave[id] = { 32 samples } / @wave[id] C Define/select a custom wave-channel waveform (32 four-bit samples, 0-15). A generated waveform table entry — see below.
@ve[len] A, B, D Volume envelope; negative = decreasing, positive = increasing. env $attack $decay on A/B. Dropped on D (noise) — see Noise below.
@wd[duty] A, B Duty cycle, 0-3 (12.5% / 25% / 50% / 75%). duty $n, direct passthrough.
@s[speed] (pitch slide) A, B, D Continuous pitch slide. pitchSlide $n (see Music Format) — this one does map over.
@v[speed],[depth],[delay] (vibrato) A, B Vibrato. vibrato $wddepth maps to the intensity nibble directly; delay is converted like a note length, then halved/rounded into the 4-bit wait field (max 30 frames: longer delays are clamped with a warning). speed is dropped (this engine's vibrato has a fixed oscillation rate with nothing to map it to).
@po[offset] (pitch offset) A, B, C Transposition-style pitch offset. Shifts the base pitch of a note
@ns[state] D Noise counter-width bit. Dropped, with a warning. This engine's noise "pitch" instead selects a whole fixed noise type — see below.
@@[id] = { ... } / @@[id] any User-defined macro; call site is replaced with the macro's contents. Fully inlined at each call site, against the calling channel's live octave/default-length state (matching real mmlgb hardware, where these are shared runtime registers a macro can leak changes out of).

Waveforms (channel C)

Real wave-channel hardware only has four output levels (mute/100%/50%/25%), which mmlgb exposes as v0-v3 per note. This engine's vol command is reserved for handling pause menu audio, so it should never be used directly in BGM/SFX data. Instead, volume must be baked directly into the waveform sample data (see Music Format). So for every (waveform, volume level) pair your MML actually uses, the converter generates a correctly-scaled 32-sample variant and emits a duty <symbol> whenever the combination changes. These generated variants are written to a side file (waveforms_new.s, or <output>_waveforms new.s without --batch) that is not automatically part of the project's real waveform table. Merge it in by hand. The header comment in the generated file explains how. Your song will not actually play the intended waveform in-game until you do this.

If you want to reuse a waveform that's already been merged into the project's real table (or hand-written some other way), use @wave[LABEL], a label instead of a numeric id, e.g. @wave[WF_SQUARE_50_VOL_7] (see the existing named waveforms). This emits duty LABEL directly with no generated-table entry at all. Because volume is baked into the waveform itself, v has no effect after a @wave[LABEL] selection and is dropped with a warning if used. Pick a differently-scaled label instead.

Wave channel pitch quirk: this engine's frequency lookup subtracts 12 from a note's raw value before indexing its shared table for the pulse channels, but not for the wave channel. As a result, the same note byte on the wave channel plays exactly one octave sharp compared to the pulse channels. The converter compensates automatically. Every wave-channel note it emits is 12 semitones lower than an equivalent pulse-channel note would be, so what you hear matches your MML source. If you ever read a converted .s file directly, the wave channel's note names will look "one octave low" compared to what you wrote. That's expected, and the generated file adds a comment there as a reminder.

Noise channel (D)

mmlgb treats the noise channel as a smooth 8-octave chromatic scale. This engine instead only has 13 fixed, hand-tuned noise types (crash, snare, dash noise, etc. See the full table on Music Format). Each is a specific hardware register pair, not derived from any scale. The converter maps every noise note to whichever fixed type has the closest actual hardware frequency. This is a many-to-one quantization, so nearby octaves can legitimately collapse onto the same output type. vol still works normally on this channel. Each fixed type's envelope pace is baked in and not adjustable per-note, which is why @ve is dropped here.

For composing a noise/drum part directly, rather than porting existing chromatic content, two converter-specific extensions select an exact fixed type instead of nearest-match:

  • n<value>. A literal noise byte, decimal (n34) or with this engine's own $ hex notation (n$22). Always needs a comma before the length: n$22,4.
  • @name = n<value>. Define a short alias once, then use it like a regular note (no comma needed): @k = n$22 then @k4. Must be defined before first use, same as @@ macros, and can't reuse a reserved macro name (@wave, @ve, @wd, @p, @po, @v, @ns, @@).

Recovering noise envelopes

@ve is dropped on the noise channel by default (above), since a fixed noise type's envelope pace isn't independently settable. There are two ways to recover it instead of losing it:

  • mml2wla.py --noise-envelope. Generates an exact new noise-table entry for every affected note (deduplicated the same way waveforms are) and writes it to noise_new.s/<output>_noise_new.s, ready to merge into the project's real noise table before its .db $ff terminator.
  • noise_envelope.py --expand in.mml out.mml. Instead rewrites only the noise channel of the MML itself into explicit volume writes/retriggers that reproduce the intended envelope curve, needing no table changes at all. This is a bit more verbose, and retriggering restarts the noise LFSR each step, unlike a real continuously-running envelope. Only channel D is touched. Everything else in the file passes through untouched.

Converter-specific directives

Written on their own line anywhere in the source (conventionally near the top), stripped before parsing. They are not part of mmlgb's own grammar:

Directive Effect
#TITLE Name Overrides the output filename for every generated label (mus<Name>Start, etc.) and generated-file symbol name. Must be a valid label: letters/digits/underscore, not starting with a digit.
#COMPOSER Name Recognized so it doesn't get misparsed as note data; otherwise has no effect on the output. Documentation only.
#FIX_REST_BLIP Opt-in fix for a pulse-channel rest-retrigger blip. See the blip workaround described on Music Format. Off by default because it costs 2 extra bytes per occurrence and the blip itself is often inaudible; turn it on per-file if you actually hear it.

Running the converter

mml2wla.py <input.mml> <output.s>
mml2wla.py --batch <input_dir> <output_dir>
mml2wla.py --noise-envelope <input.mml> <output.s>       ; see "Recovering noise envelopes" above
mml2wla.py --batch --noise-envelope <in_dir> <out_dir>

--batch converts every .mml file in a directory in one pass, sharing waveform (and, with --noise-envelope, noise-table) deduplication across the whole batch. This is what you want if you're converting a whole album or hack's worth of songs at once, since two songs using the same waveform will then share one table entry instead of duplicating it.

Output layout: each .mml file becomes one .s file matching this project's own convention exactly. A mus<Name>Start: label is followed by mus<Name>Channel0: (pulse 1 / MML channel A), Channel1: (pulse 2 / B), Channel4: (wave / C), and Channel6: (noise / D), in that order. <Name> is your output filename with its first letter capitalized (overworld.smusOverworldStart) unless overridden by #TITLE. Keep output filenames camelCase, matching every other song file in the project, so the generated labels read the same way as everything else. A channel your MML never uses gets a MUSIC_CHANNEL_FALLBACK stub instead of real data, matching every reference example song.

This output is a self-contained channel-data file. It deliberately does not touch soundPointers.s, soundChannelPointers.s, or soundChannelData.s. Wiring it into an actual index or bank is the next, separate step. See Adding Custom Music.

A naming mismatch to watch for

The disassembly's own include/musicMacros.s recently renamed its command $61 macro from rest2 to sust (see Music Format). The converter's own emitter still writes the older mnemonic rest2 for that command in its generated .s output. Functionally this is the exact same byte ($61) either way, but it means a freshly converted file may reference a macro name (rest2) that no longer exists in this project's current musicMacros.s. If you get an "undefined macro rest2" assembler error on converted output, that's why. Rename the occurrences to sust or add a rest2 alias macro before assembling.

Tempo and timing accuracy

This is worth understanding if a converted song ever sounds subtly "off": mmlgb expresses note lengths in a tempo-independent tick unit (192 per whole note) that never changes regardless of t. This engine has no runtime tempo concept at all. Every length byte in its channel data is a literal frame count. The converter:

  • Reproduces mmlgb's own tempo quantization exactly. Real mmlgb hardware doesn't hit your requested BPM exactly either. For example, t140 actually plays at about 134.7 BPM on real mmlgb, so a converted song's pace matches how mmlgb would really have played it, not the literal number after t.
  • Carries each channel's rounding error forward note-to-note instead of resetting it every note, so a channel's timeline never drifts by more than about half a frame from the exact value, no matter how long the song runs.
  • Always fully unrolls [...]n repeat blocks rather than emitting a WLA .rept for them, specifically so each iteration's rounding can stay correct relative to the others. A real Game Boy's frame rate doesn't divide evenly enough for identical-looking iterations to always be timed identically. It then performs a separate, fully lossless pass that merges any run of the resulting output lines that end up literally identical anyway.
  • Never emits a length byte as $00, which this engine reads as 256 frames, not 0. Lengths over 255 frames are always split into multiple chunks.
  • Drops redundant repeated vol/duty/vibrato/env commands that would just re-assert a value the channel already has. This was confirmed as genuinely inert on real hardware by reading code/audio.s. This is easy to assume isn't true for env in particular, since it looks like it should "restart" an envelope, but the attack/decay state machine only actually resets when a note next triggers, never in the env command's own handler.

See also