The first customer for the player outside the program it was pulled out of, and the argument for doing it: a splash needs no resident player at all. Nothing else is happening while the logo is up, so Lander owns the timer and all four channels exactly as Play does. Music UNDER a running game is still deferred, and still the harder problem. It polls the timer rather than being interrupted by it. Lander has no vector segment and waits for the screen by reading port 0x30, so it waits for a beat by reading port 0x50 - the same shape, and it brings no handler that would have to be taken away before the game starts. The tune is read off the disk. A missing one means the logo and silence and the game starts anyway, the way a missing /lander.state means the defaults stand. FOUR THINGS THIS COST, each found by running it: A subroutine cannot answer in A. CALL saves and restores it, so splashSkip handed its caller back the A it already had - the channel number of the last stepVoice - and the splash ended on its first pass whatever anybody pressed. Q is what survives a RET, which nextRandom says twenty lines away and I did not read. Port 0x3D is how many window rows are SHOWN, and it is two once putGauge runs and nothing before. A line written to row eleven went somewhere real and was displayed nowhere. A nought is not a keypress. It is what a recorded keyboard file holds while nobody is typing, and a splash that took it for a key is one no test could ever watch. And skipping has to be free. Asked after blanking the window and reading the file, a skipped splash still cost a fifth of a second - enough to push the thruster test's early capture past the frame it looks at. Asked first, it costs a pad read. player.asm no longer asks a caller for Order0 to Order3: the voice records name NoOrder instead, so a program whose tune comes from a file does not have to define four order lists it never uses. That was the file case finding a wart in the contract. Every other Lander test now skips the splash with a space - a key the game itself ignores, since it answers to q, z and the arrows - so they go on testing what they tested. The one in sound.sh presses nothing, which is what makes it the one that hears the music. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
473 lines
14 KiB
NASM
473 lines
14 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:
|
|
;
|
|
; PatchTable one two-byte address per patch
|
|
; SequenceTable one two-byte address per sequence
|
|
; VoiceStart four bytes, the patch index each voice starts on
|
|
;
|
|
; and point each voice's order cursor at a list of one-byte sequence indices ending in 0xFF.
|
|
; A tune read from a file needs none of that: useTune does all of it out of the header.
|
|
;
|
|
; It must call loadStartPatches once, then 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.
|
|
;
|
|
; INDICES THROUGH TABLES, RATHER THAN ADDRESSES, because that is the shape a tune read from a
|
|
; file has to be: the two tables are the only places an address lives, so loading a tune means
|
|
; adding a base to them and nothing else. Keeping the assembled-in form the same shape is what
|
|
; makes reading one from a file change no code here at all.
|
|
|
|
#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
|
|
LDA.0 ; Which patch: one byte of index, not an address.
|
|
INCD.0 ; Past it, on whatever comes next.
|
|
DPUP.1 0d2
|
|
STD.0.1
|
|
DPDN.1 0d2
|
|
|
|
SETD.2 PatchTable
|
|
LDD.2.2 ; The table, wherever this tune put it.
|
|
DPUA.2
|
|
DPUA.2 ; Twice, because an entry is two bytes.
|
|
LDD.2.2 ; And DP2 follows the address it is now holding.
|
|
|
|
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 ----
|
|
;
|
|
; AN ORDER LIST IS INDICES, one byte each, ending in 0xFF - the same byte that ends a sequence.
|
|
; SequenceTable is the only place a sequence's address lives.
|
|
;
|
|
; That is what makes a tune loadable without walking it: nothing inside a sequence or an order
|
|
; list is an address, so putting one in memory means adding the load address to two tables and
|
|
; nothing else. A loader that had to parse sequences looking for addresses to fix up is a
|
|
; loader that a malformed file can walk off a cliff.
|
|
;
|
|
; It was addresses here, terminated by a zero, on the reasoning that no sequence could live
|
|
; below this player's 0x3000 base. True of a loaded program and false of a boot image based 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. An address is not a good place to hide a flag unless the
|
|
; address is impossible in every program rather than in this one.
|
|
stepSequenceEnd:
|
|
LDD.0.1 ; DP0 is the order cursor, which is the first thing in the record.
|
|
LDA.0 ; Which sequence comes next.
|
|
INIB 0xFF
|
|
XOR
|
|
BRQ stepOrderEnd
|
|
|
|
INCD.0
|
|
STD.0.1 ; The order cursor, moved past this entry.
|
|
|
|
SETD.2 SequenceTable
|
|
LDD.2.2 ; The table, wherever this tune put it.
|
|
DPUA.2
|
|
DPUA.2
|
|
LDD.2.2 ; And the sequence that index names.
|
|
|
|
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
|
|
|
|
; ---- The instrument each voice starts on ----
|
|
;
|
|
; STARTING STATE IS DECLARED RATHER THAN ASSUMED. A voice whose instrument was never said
|
|
; would play on whatever the device woke up with, or worse on whatever the last tune left -
|
|
; and that is a fault this machine has met elsewhere, where a program run a second time starts
|
|
; with the memory the first run left, because loading is what initialises and running is not.
|
|
loadStartPatches:
|
|
RSTA
|
|
loadStartOne:
|
|
SETD.3 ThisChannel
|
|
STA.3
|
|
|
|
SETD.2 VoiceStartAt
|
|
LDD.2.2
|
|
DPUA.2
|
|
LDA.2 ; Which patch this voice starts on.
|
|
SETD.2 PatchTable
|
|
LDD.2.2
|
|
DPUA.2
|
|
DPUA.2
|
|
LDD.2.2
|
|
|
|
PSHD.2
|
|
POPD.0
|
|
LDA.3
|
|
CALL loadPatch ; A survives a CALL, so it is still the channel below.
|
|
|
|
INCA
|
|
INIB 0d4
|
|
CCF
|
|
SUB
|
|
BNQ loadStartOne
|
|
RET
|
|
|
|
; ---- base + offset, into wherever the caller wants it ----
|
|
;
|
|
; DP0 names two bytes of offset and DP1 two bytes to put the address in. They may be the same
|
|
; place, which is how a table is relocated in position: the low byte is written before the
|
|
; high byte is read, so nothing is clobbered under itself.
|
|
;
|
|
; The low bytes carry into the high ones, which is the whole reason ADD takes the Carry Flag.
|
|
tuneAddr:
|
|
SETD.2 TuneBase
|
|
CCF
|
|
INCD.0
|
|
LDA.0 ; The offset, low byte.
|
|
INCD.2
|
|
LDB.2 ; And the base, low byte.
|
|
ADD
|
|
INCD.1
|
|
STQ.1
|
|
DECD.0
|
|
LDA.0 ; The offset, high byte.
|
|
DECD.2
|
|
LDB.2
|
|
ADD ; With the carry the low bytes made.
|
|
DECD.1
|
|
STQ.1
|
|
RET
|
|
|
|
; Every entry of the table at DP0 turned from an offset into an address. B is how many.
|
|
tuneReloc:
|
|
PSHD.0
|
|
POPD.1
|
|
CALL tuneAddr ; A CALL hands DP0 and DP1 back, so both are still the entry.
|
|
INCD.0
|
|
INCD.0
|
|
DECB
|
|
BNB tuneReloc
|
|
RET
|
|
|
|
; ---- A tune, from wherever it was put ----
|
|
;
|
|
; DP0 names its first byte. EVERYTHING IN A TUNE IS AN OFFSET FROM THERE, so this adds the
|
|
; base to the two tables and the four order lists and nothing else in the file is touched. No
|
|
; sequence is walked, and nothing inside one is an address to be found and corrected - which
|
|
; is what makes a malformed tune something that plays wrongly rather than something that takes
|
|
; the loader with it.
|
|
;
|
|
; Q is nought if the tune was taken, and anything else if the file was not one.
|
|
useTune:
|
|
SETD.1 TuneBase
|
|
STD.0.1
|
|
|
|
; "SBTU", and version one. A file that is not a tune has to be refused here, because
|
|
; everything below reads offsets out of it and jumps to what they name.
|
|
SETD.1 TuneMagic
|
|
INIB 0d5
|
|
useTuneMagic:
|
|
LDA.0
|
|
PSHB
|
|
LDB.1
|
|
XOR
|
|
POPB
|
|
BNQ useTuneNo
|
|
INCD.0
|
|
INCD.1
|
|
DECB
|
|
BNB useTuneMagic
|
|
|
|
; The tick, straight into the timer. Three bytes, most significant first, which is the
|
|
; order the ports take them in.
|
|
LDA.0
|
|
OUTA 0x52
|
|
INCD.0
|
|
LDA.0
|
|
OUTA 0x53
|
|
INCD.0
|
|
LDA.0
|
|
OUTA 0x54
|
|
|
|
; How many of each, kept before the pointers move.
|
|
INCD.0
|
|
LDA.0
|
|
SETD.1 TunePatches
|
|
STA.1
|
|
INCD.0
|
|
LDA.0
|
|
SETD.1 TuneSequences
|
|
STA.1
|
|
|
|
; The two tables.
|
|
INCD.0
|
|
SETD.1 PatchTable
|
|
CALL tuneAddr
|
|
INCD.0
|
|
INCD.0
|
|
SETD.1 SequenceTable
|
|
CALL tuneAddr
|
|
|
|
; And an order list each.
|
|
INCD.0
|
|
INCD.0
|
|
SETD.1 Voice0
|
|
CALL tuneAddr
|
|
INCD.0
|
|
INCD.0
|
|
SETD.1 Voice1
|
|
CALL tuneAddr
|
|
INCD.0
|
|
INCD.0
|
|
SETD.1 Voice2
|
|
CALL tuneAddr
|
|
INCD.0
|
|
INCD.0
|
|
SETD.1 Voice3
|
|
CALL tuneAddr
|
|
|
|
; The starting instruments are not an offset but a place IN the file, so they are found by
|
|
; counting rather than by adding.
|
|
SETD.0 TuneBase
|
|
LDD.0.0
|
|
DPUP.0 0d22
|
|
SETD.1 VoiceStartAt
|
|
STD.0.1
|
|
|
|
; Now the tables themselves, whose entries are offsets like everything else.
|
|
SETD.0 PatchTable
|
|
LDD.0.0
|
|
SETD.1 TunePatches
|
|
LDB.1
|
|
CALL tuneReloc
|
|
SETD.0 SequenceTable
|
|
LDD.0.0
|
|
SETD.1 TuneSequences
|
|
LDB.1
|
|
CALL tuneReloc
|
|
|
|
CALL startVoices
|
|
|
|
; Q is nought, which is how this says it worked. There is no instruction that sets Q: it is
|
|
; the ALU's output and nothing else, so saying nought means doing a sum that comes to it.
|
|
RSTA
|
|
RSTB
|
|
XOR
|
|
RET
|
|
|
|
useTuneNo:
|
|
; Q is already not nought, because that is what got here.
|
|
RET
|
|
|
|
; ---- Four voices at the beginning of their order lists ----
|
|
;
|
|
; Whoever supplied the tune has set the order cursors; this sets everything else. A voice
|
|
; starts on Empty with a count of one, so its first tick runs the count out, finds the end of
|
|
; a sequence, and goes to the order list for the real first one. The beginning of a piece
|
|
; needs no special case anywhere.
|
|
startVoices:
|
|
SETD.1 Voice0
|
|
CALL startOneVoice
|
|
SETD.1 Voice1
|
|
CALL startOneVoice
|
|
SETD.1 Voice2
|
|
CALL startOneVoice
|
|
SETD.1 Voice3
|
|
CALL startOneVoice
|
|
INIA 0d4
|
|
SETD.1 Playing
|
|
STA.1
|
|
CALL loadStartPatches
|
|
RET
|
|
|
|
startOneVoice:
|
|
SETD.0 Empty
|
|
DPUP.1 0d2
|
|
STD.0.1
|
|
INCD.1
|
|
INCD.1
|
|
INIA 0d1
|
|
STA.1 ; A count of one, which runs out on the first tick.
|
|
INCD.1
|
|
STA.1 ; And live.
|
|
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:
|
|
NoOrder Empty 0d1 0d1
|
|
Voice1:
|
|
NoOrder Empty 0d1 0d1
|
|
Voice2:
|
|
NoOrder Empty 0d1 0d1
|
|
Voice3:
|
|
NoOrder Empty 0d1 0d1
|
|
|
|
Playing:
|
|
0d4
|
|
ThisChannel:
|
|
0x00
|
|
|
|
; Where this tune's tables are. Pointers rather than the tables themselves, because a tune
|
|
; read from a file puts them wherever it was put, and the engine must not care which.
|
|
PatchTable:
|
|
0x00 0x00
|
|
SequenceTable:
|
|
0x00 0x00
|
|
VoiceStartAt:
|
|
0x00 0x00
|
|
|
|
TuneBase:
|
|
0x00 0x00
|
|
TunePatches:
|
|
0x00
|
|
TuneSequences:
|
|
0x00
|
|
TuneMagic:
|
|
0x53 0x42 0x54 0x55 0x01 ; "SBTU" and version one.
|
|
|
|
; An order list of nothing, which every voice starts pointed at. WHOEVER SUPPLIES THE TUNE
|
|
; SETS THE REAL ONE - useTune out of a file's header, or a program with a tune of its own in
|
|
; eight lines - so the records below name nothing that a caller has to have defined. They used
|
|
; to name Order0 to Order3, which made four order lists part of the contract even for a caller
|
|
; whose tune comes from a file and never has any.
|
|
NoOrder:
|
|
0xFF
|
|
|
|
; The sequence a voice starts on, so that its first tick goes through the order list like
|
|
; every other bar does.
|
|
Empty:
|
|
0xFF
|
|
|