Last Week:
Volume Envelopes
This Week: Opcodes and Looping
Opcodes
So far our sound engine handles two type of data that it reads from
music data streams: notes and note lengths. This is enough to write
complex music but of course we are going to want more features. We
will want control over the sound of our notes. What if we want to
change duty cycles midstream? Or volume envelopes? Or keys? What if
we want to loop one part of the song four times? Or loop the entire
song continuously? What if we want to play a sound effect as part of a
song?
All of these types of features, features where you are
issuing commands to the engine, are going to be done through opcodes
(also called control codes or command codes). An
opcode is a
value in the data stream that tells the engine to run a specific,
specialized subroutine or piece of code. Most opcodes will have
arguments
sent along with them. For example, an opcode that changes a stream's
volume envelope will come with an argument that specifies which volume
envelope to change to.
We've actually been using an opcode for
weeks, I just haven't mentioned it. It's the opcode that ends a sound,
and we've been encoding it in our data streams as $FF. Here is the
code we've been using:
se_fetch_byte: ;---snip--- (fetch a byte and range test).opcode: ;else it's an opcode ;do Opcode stuff cmp #$FF bne .end lda stream_status, x ;if $FF, end of stream, so disable it and silence and #%11111110 sta stream_status, x ;clear enable flag in status byte lda stream_channel, x cmp #TRIANGLE beq .silence_tri ;triangle is silenced differently from squares and noise lda #$30 ;squares and noise silenced with #$30 bne .silence.silence_tri: lda #$80 ;triangle silenced with #$80.silence: sta stream_vol_duty, x ;store silence value in the stream's volume variable. jmp .update_pointer ;done ;---snip--- (do note lengths and notes, update the stream's pointer) rts Here we check if the byte read has a value of $FF. If so we turn the stream off and silence it. That's an opcode.
It
would be pretty messy if every opcode we had was just written straight
out like this. Normally we would pull this code into its own
subroutine, like this:
se_fetch_byte: ;---snip--- (fetch a byte and range test).opcode: ;else it's an opcode ;do Opcode stuff cmp #$FF ;end sound opcode bne .end jsr se_op_endsound ;call the endsound subroutine iny jmp .fetch ;grab the next byte in the stream. ;---snip--- (do note lengths and notes, update the stream's pointer) rts se_op_endsound: lda stream_status, x ;end of stream, so disable it and silence and #%11111110 sta stream_status, x ;clear enable flag in status byte lda stream_channel, x cmp #TRIANGLE beq .silence_tri ;triangle is silenced differently from squares and noise lda #$30 ;squares and noise silenced with #$30 bne .silence.silence_tri: lda #$80 ;triangle silenced with #$80.silence: sta stream_vol_duty, x ;store silence value in the stream's volume variable. rts The .opcode branch is much shorter now. If we wanted to add more opcodes, we could just add some more compares:
.opcode: ;do Opcode stuff cmp #$FF ;is it the end sound opcode? bne .not_FF jsr se_op_endsound ;if so, call the end sound subroutine jmp .end ;and finish.not_FF: cmp #$FE ;else is it the loop opcode? bne .not_FE jsr se_op_loop ;if so, call the loop subroutine jmp .opcode_done.not_FE: cmp #$FD ;else is it the change volume envelope opcode? bne .not_FD jsr se_op_change_ve ;if so, call the change volume envelope subroutine jmp .opcode_done.not_FD:.opcode_done: iny ;update index to next byte in the data stream jmp .fetch ;go fetch another byte This
will work, but it's ugly. The more opcodes we add to our engine, the
more checks we need to make. What if we have 20 opcodes? Do we really
want to do that many compares? It's a waste of ROM space and cycles.
TablesAnytime you find yourself in a situation where you are doing a lot of CMPs on one value, the answer is to use a
lookup table.
It will simplify everything! We've done it already with notes, note
lengths, song numbers and volume envelopes. Could you imagine trying
to get a note's period without using the lookup table? It would look
like this:
Is the note an A1? If so, use this period, elseIs the note an A#1? If so, use this period, elseIs the note a B1? If so, use this period, elseIs the note a C2? If so, use this period, else... (about 100 more checks)Is the note an F#9? If so, use this period, elseIs the note a rest? If so, use this periodThat's
just crazy. It would be hundreds of lines of unreadable code and you'd
run into branch-range errors too. When we use a lookup table, the code
is simplified to this:
.note: ;do Note stuff sty sound_temp1 ;save our index asl a tay lda note_table, y sta stream_note_LO, x lda note_table+1, y sta stream_note_HI, x ldy sound_temp1 ;restore data stream index Much cleaner. Again, I can't stress it enough:
if you find yourself doing lots of CMPs on a single value, use a table instead! With notes and note lengths we used a straight
lookup table of values. With song numbers and volume envelopes we used a special type of lookup table called a
pointer table, which stored data addresses. For opcodes we have two choices. We can use something called a
jump table or we can use an
RTS table.
They are almost the same and the difference in performance between the
two methods is negligible so for most programmers it's a matter of
personal preference.
I prefer RTS tables myself, but we're going to use jump tables because they are easier to explain and understand.
Jump TablesOk,
here's our problem: Our sound engine has opcodes. A lot of them,
let's say 10 or more. Each opcode has its own subroutine. When our
sound engine reads an opcode byte from the data stream, we want to
avoid a long list of CMP and BNE instructions to select the right
subroutine. How do we do that? We use a jump table.
A jump
table is similar to a pointer table: it is a table of addresses. But
whereas a pointer table holds addresses that point to the start of
data, a
jump table holds addresses that point to the start of
code (ie, the start of subroutines). For example, suppose we have some subroutines:
sub_a: lda #$00 ldx #$FF rts sub_b: clc adc #$03 rts sub_c: sec sbc #$03 rts Here is how a jump table would look using these subroutines:
sub_jump_table: .word sub_a, sub_b, sub_c Hey,
that's pretty easy. We just use the subroutine label and the assembler
will translate that into the address where the subroutine starts.
Let's make a jump table for our sound opcode subroutines:
se_op_endsound: ;do stuff rts se_op_infinite_loop: ;do stuff rts se_op_change_ve: ;do stuff rts ;etc.. more subroutines
;this is our jump tablesound_opcodes: .word se_op_endsound .word se_op_infinite_loop .word se_op_change_ve ;etc, one entry per subroutine Cool. We have a jump table now. So how do we use it?
Indirect JumpingThe 6502 let's us do some cool things. One of those things is called an indirect jump. An
indirect jump let's you stick a destination address into a zero-page pointer variable and jump there. It works like this:
.rsset $0000;first declare a pointer variable somewhere in the zero-pagejmp_ptr .rs 2 ;2 bytes because an address is always a word lda #$00 sta jmp_ptr lda #$80 sta jmp_ptr+1 jmp [jmp_ptr] ;will jump to $8000 Here we stick an address ($8000, lo byte first) into our
jmp_ptr variable. Then we do an indirect jump by using the JMP instruction followed by a pointer variable in brackets:
jmp [jmp_ptr] ;indirect jump This
instruction translates into English as "Jump to the address that is
stored in jmp_ptr and jmp_ptr+1". It's extrememly useful. We can
stick any address we want in there:
lda #$00 sta jmp_ptr lda #$C0 sta jmp_ptr+1 jmp [jmp_ptr] ;will jump to $C000 We could read an address from ROM and use that if we wanted to, for example our reset vector:
lda $FFFC sta jmp_ptr lda $FFFD sta jmp_ptr+1 jmp [jmp_ptr] ;will jump to our reset routine And we can use it in combination with our jump table:
lda sound_opcodes, y ;read low byte of address from jump table sta jmp_ptr lda sound_opcodes+1, y ;read high byte sta jmp_ptr+1 jmp [jmp_ptr] ;will jump to whatever address we pulled from the table. Pretty powerful. We can dynamically jump to any section of code we want!
ImplementationSo
we know how to build a jump table and we know how to do an indirect
jump. Let's tie it all together and stick it into our sound engine.
Let's start with
se_fetch_byte. se_fetch_byte reads a byte
from the data stream and range-checks it to see if it is a note, note
length or opcode. Recall that notes have a byte range of $00-$7F.
Note lengths have a range of $80-$9F. The opcode byte range is $A0-$FF:
se_fetch_byte: lda stream_ptr_LO, x sta sound_ptr lda stream_ptr_HI, x sta sound_ptr+1 ldy #$00 lda [sound_ptr], y bpl .note ;if < #$80, it's a Note cmp #$A0 bcc .note_length ;else if < #$A0, it's a Note Length.opcode: ;else ($A0-$FF) it's an opcode ;do Opcode stuff.note_length: ;do note length stuff.note: ;do note stuff So
we need to assign our opcodes to values between $A0 and $FF. Just as
with notes and note lengths, the opcode byte we read from the data
stream will be used as a table index (after subtracting $A0), so we
will assign our opcodes in the same order as our table:
sound_opcodes: .word se_op_endsound ;this should be $A0 .word se_op_infinite_loop ;this should be $A1 .word se_op_change_ve ;this should be $A2 ;etc, 1 entry per subroutine;these are aliases to use in the sound data.endsound = $A0loop = $A1 ;be careful of conflicts here. this might be too generic. maybe song_loop is bettervolume_envelope = $A2Now let's alter se_fetch_byte to take care of our opcodes:
se_fetch_byte: lda stream_ptr_LO, x sta sound_ptr lda stream_ptr_HI, x sta sound_ptr+1 ldy #$00.fetch: lda [sound_ptr], y bpl .note ;if < #$80, it's a Note cmp #$A0 bcc .note_length ;else if < #$A0, it's a Note Length.opcode: ;else ($A0-$FF) it's an opcode ;do Opcode stuff jsr se_opcode_launcher ;launch our opcode!!! iny ;next position in the data stream lda stream_status, x and #%00000001 bne .fetch ;after our opcode is done, grab another byte unless the stream is disabled rts ; in which case we quit (explained below).note_length: ;do note length stuff.note: ;do note stuff I
added a call to a subroutine called se_opcode_launcher and a little
branch. Not a big change is it? But there's an important detail
here. se_opcode_launcher will be a short, simple subroutine that will
read from the jump table and perform an indirect jump. It looks like
this:
se_opcode_launcher: sty sound_temp1 ;save y register, because we are about to destroy it sec sbc #$A0 ;turn our opcode byte into a table index by subtracting $A0 ; $A0->$00, $A1->$01, $A2->$02, etc. Tables index from $00. asl a ;multiply by 2 because we index into a table of addresses (words) tay lda sound_opcodes, y ;get low byte of subroutine address sta jmp_ptr lda sound_opcodes+1, y ;get high byte sta jmp_ptr+1 ldy sound_temp1 ;restore our y register iny ;set to next position in data stream (assume an argument) jmp [jmp_ptr] ;indirect jump to our opcode subroutine Short
and simple. So why did I wrap this code in its own subroutine? Why
not just stick this code as-is in the .opcode branch of se_fetch_byte?
Because we need a place to return to.
The JSR and RTS
instructions work as a pair. They go hand in hand. They need each
other. Without going into too much detail, this is what goes on behind
the scenes:
JSR sticks a return address on the stack and jumps to a
subroutine. One way to look at it is to think of JSR as a JMP that
remembers where it started from.
RTS pops the return address off the stack and jumps there.
So JSR leaves a treasure map for RTS to pick up and follow later. The
key point here is that
RTS expects a return address to be waiting for it on the stack.
Now our opcode subroutines all end in an RTS instruction. Do you see the potential problem here?
We
call our opcode subroutines using an indirect jump. This requires us
to use a JMP instruction, not a JSR instruction. A JMP instruction
doesn't remember where it started from. No return address is pushed
onto the stack with a JMP instruction. So when we jump to our opcode
subroutine and hit the RTS instruction at the end, there is no return
address waiting for us! The RTS will pull whatever random values
happen to be on the stack at the time and jump there. We'll end up
somewhere random and our program will surely crash!
To fix this,
we wrap our indirect jump in a subroutine, se_opcode_launcher. We call
it with a JSR instruction, completing the JSR/RTS pair:
jsr se_opcode_launcher ;this jsr will let us remember where we came from This
JSR instruction will stick a return address on the stack for us. Then
inside se_opcode_launcher we perform our indirect jump to our desired
opcode subroutine. Now when we hit that RTS instruction at the end of
the opcode subroutine we have a return address waiting for us on the
stack. Our program returns back to where we started. We are safe.
Opcode SubroutinesWith our opcode launcher written, we are all set up to make opcodes. We already have one written: the
endsound
opcode. This is the opcode we will use to terminate sound effects.
Sound effects don't loop continuously like songs do, so they need to be
stopped. Let's take a look again:
se_op_endsound: lda stream_status, x ;end of stream, so disable it and silence and #%11111110 sta stream_status, x ;clear enable flag in status byte lda stream_channel, x cmp #TRIANGLE beq .silence_tri ;triangle is silenced differently from squares and noise lda #$30 ;squares and noise silenced with #$30 bne .silence ; (this will always branch. bne is cheaper than a jmp).silence_tri: lda #$80 ;triangle silenced with #$80.silence: sta stream_vol_duty, x ;store silence value in the stream's volume variable. rts This opcode is special. It's the reason for the check after the call to se_opcode_launcher:
se_fetch_byte: ;---snip---.opcode: ;else ($A0-$FF) it's an opcode ;do Opcode stuff jsr se_opcode_launcher iny ;next position in the data stream lda stream_status, x and #%00000001 bne .fetch ;after our opcode is done, grab another byte unless the stream is disabled rts ; in which case we quit (explained below) ;---snip--- Normally,
we want se_fetch_byte to keep fetching bytes until it hits a note.
Recall that with note lengths we jumped back to .fetch after setting
the new note length. This is because after setting the length of the
note, we needed to know WHAT note to play. So we fetch another byte.
The same thing is true of opcodes. If we change the volume envelope
with an opcode, great! But we still need to know what note to play
next. If we use an opcode to switch our square's duty cycle, great!
But we still need to know what note to play next. If we use an opcode
to loop back to the beginning of the song, that's great! But we still
need to read that first note of the song. This is why we jump back to
fetch a byte after we run an opcode.
The ONE exception to this
rule is when we end a sound effect. We are terminating the sound
effect completely, so there is no next note. We don't want to fetch
something that isn't there, so we need to skip the jump. That's why we
check the status byte after we run the opcode. If the stream is
disabled by the endsound opcode, we are finished. Otherwise, fetch
another byte.
LoopingThe next opcode in our list is the
loop
opcode. This is the opcode that we will stick at the end of every song
to tell the sound engine to play the song again, and again and again.
It is actually quite easy to implement. It takes a
2-byte argument, which is
the address to loop back to. The subroutine looks like this:
se_op_infinite_loop: lda [sound_ptr], y ;read LO byte of the address argument from the data stream sta stream_ptr_LO, x ;save as our new data stream position iny lda [sound_ptr], y ;read HI byte of the address argument from the data stream sta stream_ptr_HI, x ;save as our new data stream position data stream position sta sound_ptr+1 ;update the pointer to reflect the new position. lda stream_ptr_LO, x sta sound_ptr ldy #$FF ;after opcodes return, we do an iny. Since we reset ;the stream buffer position, we will want y to start out at 0 again. rts The
first thing to notice about this subroutine is that it reads two bytes
from the data stream. This is the address argument that gets passed
along with the opcode. To make it clear, let's look at some example
sound data:
song1_square1: .byte eighth ;set note length to eighth notes .byte C5, E5, G5, C6, E6, G6, C5, Eb5, G5, C6, Eb6, half, G6 ;play some notes .byte loop ;this alias evaluates to $A1, the loop opcode .word song1_square1 ;this evaluates to the address of the song1_square1 label ;ie, the address we want to loop to. After
the "loop" opcode comes a word which is the address to loop back to.
In this example I chose to loop back to the beginning of the stream
data.
So what does our loop opcode do? It reads the first byte
of this address argument (the low byte) and stores it in
stream_ptr_LO. Then it reads the second byte of the address argument
(the high byte) and stores it in stream_ptr_HI. These are the
variables that keep track of our data stream position! The loop opcode
just changes these values to some address that we specify. Not too
complicated at all. The last step is to update the actual pointer (
sound_ptr) so that the next byte we read from the data stream will be the first note we looped back to.
In
the example sound data above I looped back to the beginning of the
stream data, but there's nothing stopping me from looping somewhere
else:
song1_square1:;intro, don't loop this part .byte quarter .byte C4, C4, C4, C4.loop_point: ;this is where we will loop back to. .byte eighth ;set note length to eighth notes .byte C5, E5, G5, C6, E6, G6, C5, Eb5, G5, C6, Eb6, half, G6 .byte loop ;this alias evaluates to $A1, the loop opcode .word .loop_point ;this evaluates to the address of the .loop_point label ;ie, the address we want to loop to. Technically
we can also "loop" to a forward position, in which case it's actually
more like a jump than a loop. That's all a loop is really: a jump...
backwards.
Changing Volume EnvelopesLet's write the opcode subroutine to change volume envelopes. This one is even easier. It takes
one argument, which will be
which volume envelope to switch to:
se_op_change_ve: lda [sound_ptr], y ;read the argument sta stream_ve, x ;store it in our volume envelope variable lda #$00 sta stream_ve_index, x ;reset volume envelope index to the beginning rts That's it!
Changing Duty CyclesNow let's add an opcode that will change the duty cycle for a square stream. This one also takes
one argument: which duty cycle to switch to.
se_op_duty: lda [sound_ptr], y ;read the argument (which duty cycle to change to) sta stream_vol_duty, x ;store it. rts Done! Now we have the subroutine, but we still need to add it to our jump table:
sound_opcodes: .word se_op_endsound ;this should be $A0 .word se_op_loop ;this should be $A1 .word se_op_change_ve ;this should be $A2 .word se_op_duty ;this should be $A3 ;etc, 1 entry per subroutine;these are aliases to use in the sound data.endsound = $A0loop = $A1volume_envelope = $A2duty = $A3And it's ready to use:
song0_square1:;intro, don't loop this part .byte quarter .byte C4, C4, C4, C4.loop_point: ;this is where we will loop back to. .byte duty, $B0 ;change the duty cycle .byte volume_envelope, ve_blip_echo ;change the volume envelope .byte eighth ;set note length to eighth notes .byte C5, E5, G5, C6, E6, G6 ;play some notes .byte duty, $30 ;change the duty cycle .byte volume_envelope, ve_short_staccato ;change volume envelope .byte C5, Eb5, G5, C6, Eb6, half, G6 ;play some eighth notes and a half note .byte loop ;loop to .loop_point .word .loop_point Readabilitysound_engine.asm
is getting pretty bulky with all these subroutines. It will only get
bigger as we add more opcodes. It's nice to have all of our opcodes
together in one place, but it's annoying to have to scroll around to
find them. So let's pull all of our opcodes into their own file:
sound_opcodes.asm. Then, at the bottom of sound_engine.asm, we can .include it:
.include "sound_opcodes.asm" ;our opcode subroutines, jump table and aliases
.include "note_table.i" ;period lookup table for notes
.include "note_length_table.i"
.include "vol_envelopes.i"
.include "song0.i" ;holds the data for song 0 (header and data streams)
.include "song1.i" ;holds the data for song 1
.include "song2.i"
.include "song3.i"
.include "song4.i"
.include "song5.i"
.include "song6.i" ;oooh.. new song!
I
gave it the extension .asm because it contains code as well as data,
and I like to be able to tell at a glance what files have what in
them. Now whenever we want to add new opcodes, or tweak old ones, we
have them nice and compact in their own file.
Updating Sound DataWhenever
we add new things to our sound engine, we have to think about how it
will affect our old sound data. This week we added opcodes, which will
change our songs and sound effects terminate. Before we were
terminating them with $FF. This won't work anymore because $FF doesn't
do anything. For songs, we should terminate with "loop" followed by an
address to loop to. With sound effects we should terminate with the
opcode "endsound". See the included songs and sound effects for
examples.
RTS TablesWe talked about jump tables and
indirect jumping this week. Another method for doing the same thing
involves something called an
RTS table and the
RTS Trick.
I won't cover it in these tutorials, but if you are curious to know how
this works you can read
this nesdev wiki article I wrote about the RTS
Trick.
Putting It All TogetherDownload and unzip the
opcodes.zip sample files. Make sure the following files are in the same folder as NESASM3:
opcodes.asm
sound_engine.asm
sound_opcodes.asm
opcodes.chr
note_table.i
note_length_table.i
vol_envelopes.i
song0.i
song1.i
song2.i
song3.i
song4.i
song5.i
song6.i
opcodes.bat
Double click opcodes.bat. That will run NESASM3 and should produce the opcodes.nes file. Run that NES file in FCEUXD SP.
Use the controller to select songs and play them. Controls are as follows:
Up: Play
Down: Stop
Right: Next Song/SFX
Left: Previous Song/SFX
Song0 is a silence song. Not selectable.
Song1 is a boss song from The Guardian Legend. Now it loops!
Song2 is the same short sound effect from last week. Terminated with endsound.
Song3 is a song from Dragon Warrior. Now it loops!
Song4 is the same song4 as last week, but now it loops!
Song5 is a short sound effect, terminated with the endsound opcode.
Song6
should be familiar to readers of this forum. Do you recognize it? It
utilizes opcodes for changing duty cycles and volume envelopes. Plus
it loops!
Try adding your own songs and sound effects in. Try to add your own opcodes too. Here's some ideas for opcodes:
1. Trigger a sound effect mid-song
2. Implement duty cycle envelopes (similar to volume envelopes). Then make an opcode that allows you to change it.
3. Finite loops
Next Week:
more opcode fun. Finite Loops, Changing Keys and Autom... .