unitracker-grammar
The canonical unitracker document as a
Lark grammar, with an LALR parser for it and the note
table its expressions read as F(k), and the modulator expression language: checked, printed
in its one text and compiled to Python.
A document names no player. It holds everything it plays: instruments, programs, expressions, code, phrases and the tables they index. Its only constants are the note table at its clock and the header defaults (PAL clock, 3 voices, tempo 6). The grammar is format 2.0's, and refuses every other version.
After meta, each optional and in this order: clock, voices, tempo; slots, per cell
its modulator slots in firing order, each with its default binding and the events it holds and
resets on (slots: {freq: {wave: {mod: note(), reset: [row.note]}, tick: {hold: [step-1, row]}}});
order, the phases of a tick (order: [events, ctrl, pulse, filter, freq, pw, cut, read]);
state, per cell its state variables and their sizes in bytes (state: {freq: {t: 1}, pw: {r: 1, q: 2}}); regs, the 25 SID registers' values before tick 0 (regs: [0, 0, ..., 15]); tables; then the definitions that are not song content, each kind a top-level
section with records at 2 spaces: mod: (templates keyed by their signature, an expression or a
code block at 4 spaces: vib(l, c, s): |, walk(s): v + s, note(): F(N)), restart:,
ins:, ctrl:, pw:, filter:; then init, and song:, whose rows' def: holds their
phrases. A slot. prefix on a cell's row key, instrument field or command (tick.freq: vib(0,4, 64), !tick.freq:none) names the slot it binds or stops; a call binds a template wherever a cell
is bound, its integer arguments in the signature's order without spaces. A modulated cell's
stops are none, hold and reset (φ back to 0, the binding kept), as a row key's value and a
command ({pw: ptab(3), pw: reset}, !pw:ptab(3)!pw:reset); a cell's keys may repeat, and the
model keeps them in the order written.
A program record (restart, ctrl, pw, filter) may be a template keyed by its signature
(arp(a, b): [{ctrl: 65, note: +a}, {wait: a, note: -b}, {loop: 0}]), its rows reading its
parameters where they take an integer, as a relative note (+a, -a), as a cell value alone
(pw: b) and as a call's argument (pw: walk(b)); a call binds it wherever a program's name
stands (ctrl: arp(7,4), restart: hr(2,9,220), run: arp(7,4), !run:arp(7,4)), its
section giving its kind. Any definition may also stand inline at its use: a program after its
kind in run: and !run: (run: pw[{pw: 5}, end]), an instrument after an event's note
(C4.{ad: 9, sr: 240}), a phrase with its transpose in a play entry (play: [[C4, -*3]+2, ~])
and, wherever a cell is bound, an anonymous code body (asm{lda v; up:; adc #1; sta v; bne up; rts}); a command and a slot's default also bind a quoted expression (!pw:'v + 40'). What each
construct plays is unitracker-player's
semantics.
Install
pip install "unitracker-grammar @ git+https://github.com/anarkiwi/unitracker-grammar"
Use
from unitracker_grammar import parse, CanonicalError, frequency, table
tree = parse(text) # a lark.Tree, or CanonicalError naming the line and column
frequency(57) # F(57): A4 at the PAL clock
table(clock_hz=1022727) # F(k) for every note whose word fits, from C0, at NTSC
parser() is the cached lark.Lark parser and GRAMMAR the grammar file resource.
Expressions
unitracker_grammar.expr owns the modulator expression language. An expression is a stdlib
ast node, read from a text by tree or from a parse tree's expr and fexpr rules by the
Nodes transformer.
from unitracker_grammar import expr, frequency
expr.canonical("1024 + 16*min(3, φ)") # '16*min(3, φ) + 1024', its one text
fn = expr.compile_expr("v + W[φ]", {"_F": frequency, "_T": {"W": expr.indexed([1, 2])}})
fn(10, 1, 48) # (v, φ, N, n=0) -> 12
v is the cell's value, φ the modulator's tick count, N the sounding note and n the event
note; a template's other lowercase names are its parameters, which params lists and bind
replaces with integers. source is the Python source of an expression (ExpressionError outside the grammar),
printed the one text of a node and written an integer literal as an int. // and % raise
ZeroDivisionError on a divisor of 0; with zero=True they are 0.
Code templates
unitracker_grammar.code owns the code templates: NMOS 6502 routines but brk, jsr
and rti, with no indirect modes and no comments, that end in rts. A routine is a tuple of
statements, each a Label(name) or an Op(mnemonic, mode, operand): mode is implied
(lsr bare is the accumulator's), immediate (Imm(value, byte): #7, #p, #<p, #>p),
memory, memory,x or memory,y (Mem(symbol, offset, index): v, v+1, G.lo+3,x), or
label (a branch's or jmp's label name).
from unitracker_grammar import code, parse
ops = code.read("lda #c\nsta v\nrts\n") # checked statements, or CodeError
code.printed(ops) # 'lda #c\nsta v\nrts\n', its one text
code.params(ops), code.symbols(ops) # ('c',), ('v',): in first-use order
code.templates(parse(text)) # {name: statements} of a document's code blocks
code.state(parse(text)) # {cell: ((name, size), ...)} of its state line
Memory symbols are v (2 bytes: in, the value before the slot; out, the modulator's value),
phi (2, φ mod 65536, read-only), nn and ne (the low bytes of N and n), the state
variables of the template's cell, and T.lo / T.hi (each entry's low and high byte) for a table
T. A code slot fires where an expression would: v, phi, nn, ne and the voice's copy of
its cell's state are set, A, X, Y and the flags (but I) are 0, and the routine runs to an rts
on an empty stack, within 4096 instructions; v wrapped to the cell's width is its value and the
state is stored back. A symbol that is none of these, or a state variable of another cell, is
refused when the template is bound. F.lo / F.hi are the note table's bytes, entry k those
of frequency(k, clock_hz) for k 0-255.
code.signature("vib(l, c, s)") # ('vib', ('l', 'c', 's')), CodeError on a repeat
code.declare("vib(c)", ("c",), {"t"}) # the signature, if it declares what the body reads
code.call("vib(0,-4,32)") # Call(name='vib', args=(0, -4, 32))
code.inline(code.read("lda v\nrts\n")) # 'asm{lda v; rts}', an anonymous body's one text
code.signatures(parse(text)) # {name: parameters} of its signed templates, checked
code.programs(parse(text)) # {name: (kind, parameters)} of its program templates
code.programs checks a document's program templates: each signature declares exactly what
its rows read (code.program_reads(body, declared), in first-use order), a program keyed by its
name and everything outside the program records read no parameter, a parameter is never part of
a larger cell expression nor read by an inline program, and no name is two templates', of
programs or modulators. code.call reads a parameter argument as its name
(call("pt(b,2)") is Call("pt", ("b", 2))).
In the parse tree each definition section is a def_KIND tree (def_mod, def_restart,
def_ins, def_ctrl, def_pw, def_filter, def_phrase) of key tokens each followed by its
value: a template's key a SIGNATURE token and its value an expr or code tree, a program
template's a PROGRAM_SIG. A call is one CALL token (vib(0,-4,32)), a parameter a row reads
a PARAM (a REL_PARAM, +a, in a note) outside any factor, an inline program a
run_program (a PROGRAM_KIND token, then its program), an inline instrument an
instrument where an INS token may stand, an inline phrase a phrase where a PHRASE token
may, an anonymous body an asm tree of code_* statements (code.Statements reads it as a
code), and a command's quoted expression an fexpr.
Documents
unitracker_grammar.document is the document as typed, frozen dataclasses: read builds one from
a text, its classes build one directly, and printed writes its one canonical text, so
printed(read(text)) == text and read(printed(doc)) == doc.
from unitracker_grammar.document import (
Call, Code, Document, Event, Expr, Header, Instrument, Play, Program, Signed, Slot, SongRow,
printed, read, replace, signature,
)
vib = Code.of("lda v\nclc\nadc #c\nsta v\nrts") # checked statements
doc = Document(
Header(meta={"title": "T", "author": "A"}, slots={"freq": [Slot("wave", Call("note")), Slot("tick")]}),
mods=[(signature("vib", ["c"]), vib), ("note()", Expr("F(N)"))],
defs={"ins": [("i1", Instrument({"ad": 9, "sr": 240, "ctrl": Program([{"ctrl": 65}])}))]},
rows=[SongRow(
[Play("p1"), Play("p1", Signed(12)), "~"],
{"phrase": [("p1", [Event("C4", "i1", {"tick.freq": Call("vib", (3,))}), Event("-", steps=3)])]},
)],
loop=0,
)
text = printed(doc)
doc = replace(doc, loop=None) # edits are dataclasses.replace
| class | holds |
|---|---|
Document(header, tables, mods, defs, init, rows, loop) |
tables {name: ints}; mods the top-level templates (signature, Expr | Code); defs the top-level restart, ins, ctrl, pw and filter sections (Sections, or {kind: [(name, value)]}); init events or None; loop the row it loops to, None for end |
Header(version, meta, clock, voices, tempo, slots, order, state, regs) |
each line, None where not written; version VERSION, "2.0"; meta texts as written ("'A, B'" quoted); slots {cell: [Slot]}, state {cell: [(name, size)]}; slot(cell) its Slots |
Slot(name, mod, hold, reset) |
a slot's default binding and its events ("note-1", "row.freq") |
SongRow(play, defs) |
play entries (Play(phrase, transpose), "~", "end"; a phrase is a name or inline events) and its phrase Section |
Section(kind, records) |
(name, value) records of one kind; a template's name is its signature (arp(a, b)), its rows reading parameters as strings (ad: "a", note: "+a", Call("walk", ("b",))) |
Instrument(fields), Row(keys) |
{key: value}, slot.key for a slot's, kept in the grammar's order |
Program(rows, loop) |
Rows (or dicts), then end (loop None) or {loop: k} |
Event(note, ins, commands, keep_pw, keep_filter, steps) |
ins a name or an Instrument; commands {key: value}, ("legato", True); steps the *k |
Expr(text), Call(name, args), Code(statements) |
an expression, a call, a routine (an asm{...} body where it binds) |
Run(kind, program) |
an inline program after its kind in run: or !run: |
Signed |
an int written with its sign: note: +0, a transpose, !freq:-3 |
A leaf is otherwise an int or a str as written (a name, a note, none, hold, 129^8, + 3).
printed wraps flow collections and plain scalars as PyYAML's emitter does at width 100 (a break
before an item, or at a scalar's space, once a line is past the width; continuing at the
collection's indentation, a scalar's 2 deeper) but never the slots, order and state
lines, and quotes an expression in a flow collection that holds a comma, and in a command or a
slot's default always, and nowhere else. That is a document's one canonical text: the grammar also admits other
line wraps, which read accepts and printed writes canonically.
tests/roundtrip.py is a pytest plugin that checks every document a test parses is its one
canonical text, printed(read(text)) == text (a test marked noncanonical reads other spellings);
over unitracker-player's suite: PYTHONPATH=$GRAMMAR/tests pytest -p roundtrip in its checkout.
tools/size.py measures the grammar and the time to build its parser.
Grammar
src/unitracker_grammar/canonical.lark; the document
format and the meaning of each construct are described in unitracker's
docs/format.
Metadata
Release files for unitracker-grammar 0.11.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| unitracker_grammar-0.11.0.tar.gz | 60.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| unitracker_grammar-0.11.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 99.3 kB
Release files / unitracker_grammar-0.11.0.tar.gz
| Download URL | unitracker_grammar-0.11.0.tar.gz |
|---|---|
| Size | 60.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1f6b4f979f658bd9623a811e2e9be28960c171f0888a8b234802f79dc25c0e91
|
|
BLAKE2b-256 checksum How to use checksums |
ae25e1436e79431174c5ef6fd2237f5f9e025ae230e25179de47de9c54706f62
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logRelease files / unitracker_grammar-0.11.0-py3-none-any.whl
| Download URL | unitracker_grammar-0.11.0-py3-none-any.whl |
|---|---|
| Size | 38.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a5d654ba99a7b8818621adb877383bb52bc3500138a178915ee4d9f0501934e3
|
|
BLAKE2b-256 checksum How to use checksums |
0ae58c838dfa1b6196f8f938bd60113d7f0d1287876f563ef6e6d9daacb13fb7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logSecurity for unitracker-grammar 0.11.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for unitracker_grammar-0.11.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.