The rung between M3 and M4, and the point of doing it before the format: the engine learns the tracker's model with the tune still assembled in, so M4 becomes serialising a thing that exists rather than designing a thing that does not. A voice no longer walks one long track. 0xFF now means THIS SEQUENCE ended, and the voice takes the next address from an order list of its own. That is where repetition comes from, and it costs no notation: the bass plays the same sequence in the first bar and the last and it is written once. Per voice rather than one shared table of four-column rows, because a voice's order cursor is then a pointer it advances by itself - the same LDD and STD move everything else here makes. Four columns is how it reads, not how it is stored. Sequences also carry COMMANDS, which take no tick: the reader acts and reads the next event on the same boundary. One is defined, 0x80, which plays the rest of that voice on another patch, and the other 125 values are left alone. A patch change reshapes whatever is still ringing on the voice and nothing can be done about that - a channel has one set of parameters and a note in its release is using them - so it is a fact about the hardware and the cure is a rest, which is the composer's. The engine moved to Libraries/player.asm rather than being copied into the test a second time, now that it is big enough to drift. Play supplies the tune and the beat; the library supplies the scheduler, the patch loader and a voice's state. Verified by rendering: bar for bar identical across the move. AND THE TEST FOUND A REAL FLAW IN THE FORMAT, which is the whole argument for building the reader first. The order list ended with 0x0000, on the reasoning that no sequence could live below the 0x3000 this program is based at. True of a loaded program, false of a boot image whose data starts at zero - so the first test written against it read its own first sequence as the end of the list and played nothing at all. It is 0xFFFF now, which mirrors the 0xFF ending a sequence and is impossible everywhere: a sequence at 0xFF00 or above has fewer bytes left than it needs. An address is a poor place to hide a flag unless the address is impossible in every program, not just this one. One order list in the test now really ends, because otherwise nothing reached the terminator at all: every voice sat on a long rest and the break went unnoticed. With it, breaking the test reads garbage past the end and the counter sums two notes at 781 hertz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
210 lines
6.7 KiB
NASM
210 lines
6.7 KiB
NASM
; player.asm
|
|
; Four voices on one clock: the part of a music player that is not the music.
|
|
; Written by Anachronaut
|
|
;
|
|
; A program that includes this supplies the tune - four order lists called Order0 to Order3,
|
|
; and the sequences they name - and gets the scheduler, the patch loader and the state that
|
|
; goes with them. It does NOT supply the beat: setting the timer up and waiting on it belongs
|
|
; to the program, because how long a piece rings at the end and what stops it are its business
|
|
; and not this file's.
|
|
;
|
|
; What a sequence is, what a command is and where repetition comes from are all described in
|
|
; Apps/Play.asm, which is the first thing to read this.
|
|
;
|
|
; The caller must define, in its Data Segment:
|
|
;
|
|
; Order0 Order1 Order2 Order3 a table of sequence addresses each, ending in 0xFFFF
|
|
;
|
|
; each of which is a table of sequence addresses ending in 0xFFFF, and must call stepVoice once
|
|
; a tick for each voice, with A holding the channel and DP1 the voice's record. Playing counts down as voices run out, and reaching nought is the piece
|
|
; being over.
|
|
|
|
#Program
|
|
|
|
; ---- One voice, one tick ----
|
|
;
|
|
; A is the channel and DP1 is the voice's six bytes. Both survive the CALL that got here,
|
|
; which is what lets the caller say which voice it means in two instructions.
|
|
;
|
|
; +0 order cursor +2 sequence cursor +4 count +5 live
|
|
stepVoice:
|
|
SETD.3 ThisChannel
|
|
STA.3 ; A is wanted for other things between here and using it.
|
|
|
|
DPUP.1 0d5
|
|
LDA.1 ; Live?
|
|
DPDN.1 0d5
|
|
BRA stepDone
|
|
|
|
DPUP.1 0d4
|
|
LDA.1
|
|
DECA
|
|
STA.1 ; One tick less of whatever is sounding.
|
|
DPDN.1 0d4
|
|
BNA stepDone ; Still holding it.
|
|
|
|
; ---- The count ran out, so this is a boundary ----
|
|
;
|
|
; Select the channel FIRST. Every sound port below writes to whichever channel was last
|
|
; named, so a voice that forgot would be playing somebody else's part.
|
|
LDA.3
|
|
OUTA 0x41
|
|
RSTA
|
|
OUTA 0x45 ; Let go of the note that just ended.
|
|
|
|
DPUP.1 0d2
|
|
LDD.0.1 ; DP0 is now this voice's place in its sequence.
|
|
DPDN.1 0d2
|
|
|
|
; ---- Read events until one of them takes time ----
|
|
;
|
|
; A command takes none, and neither does the end of a sequence, so both come back here.
|
|
; A sequence of nothing but commands would spin in this loop for ever, which is a hang
|
|
; rather than a wrong note - the one malformed sequence worth a compiler refusing.
|
|
stepEvent:
|
|
LDA.0
|
|
INIB 0xFF
|
|
XOR ; XOR answers in Q and leaves A holding the event.
|
|
BRQ stepSequenceEnd
|
|
|
|
; The top bit says command, and 0xFF is already dealt with above.
|
|
INIB 0x80
|
|
AND
|
|
BNQ stepCommand
|
|
|
|
; ---- A note, or a rest, and how long it lasts ----
|
|
INCD.0
|
|
LDB.0
|
|
INCD.0
|
|
DPUP.1 0d2
|
|
STD.0.1 ; The sequence cursor, moved past this event.
|
|
DPUP.1 0d2
|
|
STB.1 ; And the count it will be held for.
|
|
|
|
; A is still the event. Zero is a rest, which is a duration with nothing started.
|
|
BRA stepDone
|
|
OUTA 0x44 ; Writing the note is what starts it.
|
|
stepDone:
|
|
RET
|
|
|
|
; ---- 0x80: play the rest of this voice on that patch ----
|
|
;
|
|
; The address follows the command. The sequence cursor is put away before the patch is loaded
|
|
; and taken out again after, because loadPatch walks DP0 and a CALL hands back the pointer it
|
|
; was given rather than the one this needs next.
|
|
stepCommand:
|
|
INCD.0
|
|
LDD.2.0 ; DP2 is the patch named after the command.
|
|
INCD.0
|
|
INCD.0 ; Past the address, on whatever comes next.
|
|
DPUP.1 0d2
|
|
STD.0.1
|
|
DPDN.1 0d2
|
|
|
|
PSHD.2
|
|
POPD.0 ; DP0 is the patch.
|
|
LDA.3 ; And A the channel, which loadPatch selects.
|
|
CALL loadPatch
|
|
|
|
DPUP.1 0d2
|
|
LDD.0.1 ; Back to where the sequence had got to.
|
|
DPDN.1 0d2
|
|
BRI stepEvent
|
|
|
|
; ---- The sequence ended, so take the next one from this voice's order list ----
|
|
;
|
|
; 0xFFFF ENDS THE LIST, which mirrors the 0xFF that ends a sequence, and testing the high byte
|
|
; alone is enough: a sequence beginning at 0xFF00 or above has fewer bytes of memory left than
|
|
; it needs, so no real one can be there.
|
|
;
|
|
; It was a zero to begin with, on the reasoning that this player's data is based at 0x3000 and
|
|
; nothing could live below it. That is true of a loaded program and false of a boot image,
|
|
; whose data starts at 0x0000 - so the first test written against it read its own first
|
|
; sequence as the end of the list and played nothing at all. An address is not a good place to
|
|
; hide a flag unless the address is impossible everywhere.
|
|
stepSequenceEnd:
|
|
LDD.0.1 ; DP0 is the order cursor, which is the first thing in the record.
|
|
LDA.0
|
|
INIB 0xFF
|
|
XOR
|
|
BRQ stepOrderEnd
|
|
|
|
LDD.2.0 ; DP2 is the sequence it names.
|
|
INCD.0
|
|
INCD.0
|
|
STD.0.1 ; The order cursor, moved past this entry.
|
|
|
|
DPUP.1 0d2
|
|
STD.2.1 ; And the sequence cursor set to the new one.
|
|
DPDN.1 0d2
|
|
|
|
PSHD.2
|
|
POPD.0
|
|
BRI stepEvent ; Which has events in it, so read one.
|
|
|
|
stepOrderEnd:
|
|
; Nothing left for this voice. The gate is already down, so its last note is fading.
|
|
DPUP.1 0d5
|
|
RSTA
|
|
STA.1
|
|
SETD.3 Playing
|
|
LDA.3
|
|
DECA
|
|
STA.3
|
|
RET
|
|
|
|
; ---- A patch, onto the channel named by A ----
|
|
;
|
|
; A count, then that many pairs of parameter and value: the format SoundPatch writes and the
|
|
; one Lander already plays. B holds what is left, which costs nothing to keep - a CALL saves
|
|
; B, so a caller's count is not disturbed by a patch being loaded inside its loop.
|
|
;
|
|
; The channel is selected FIRST. Every parameter write below lands on whichever channel was
|
|
; named last, so a patch loaded without one would be quietly rewriting somebody else's voice.
|
|
loadPatch:
|
|
OUTA 0x41
|
|
LDB.0
|
|
INCD.0
|
|
loadPatchPair:
|
|
LDA.0
|
|
OUTA 0x42
|
|
INCD.0
|
|
LDA.0
|
|
OUTA 0x43
|
|
INCD.0
|
|
DECB
|
|
BNB loadPatchPair
|
|
RET
|
|
|
|
#Data
|
|
|
|
; Six bytes a voice: order cursor, sequence cursor, count, live.
|
|
;
|
|
; A LABEL WRITTEN HERE COMES OUT AS ITS ADDRESS, two bytes, most significant first - which is
|
|
; exactly the shape LDD reads. So a voice starts pointed at its order list and at Empty
|
|
; without a line of code: nothing relocates on this machine, so the address the assembler
|
|
; wrote is the address it will have.
|
|
;
|
|
; Empty is a sequence of nothing but its end marker, and the count starts at one. The first
|
|
; tick runs the count out, finds the end, and goes to the order list for the real first
|
|
; sequence - so the beginning of a piece needs no special case anywhere.
|
|
Voice0:
|
|
Order0 Empty 0d1 0d1
|
|
Voice1:
|
|
Order1 Empty 0d1 0d1
|
|
Voice2:
|
|
Order2 Empty 0d1 0d1
|
|
Voice3:
|
|
Order3 Empty 0d1 0d1
|
|
|
|
Playing:
|
|
0d4
|
|
ThisChannel:
|
|
0x00
|
|
|
|
; The sequence a voice starts on, so that its first tick goes through the order list like
|
|
; every other bar does.
|
|
Empty:
|
|
0xFF
|
|
|