The Apple IIgs video memory is controlled by the 1MHz Mega II chip, so directly accessing the Super Hi-Res page in bank $E1 is slower than accessing other memory. How can I render in "fast" memory, and then just copy the final result to "slow" memory?
Fast Screen Refresh With PEI Slamming
(Or: Dirty Tricks With the Direct Page)
This article is based on my KansasFest 2004 presentation "Code Secrets of Wolf 3D."
Drawing super high-resolution (SHR) graphics on the Apple IIgs is slow. Unfortunately, the SHR screen's memory in bank $E1 is located in "slow RAM" — that is, memory controlled by the Mega II chip which emulates 1 MHz 8-bit Apple IIs. Access to this memory is gated to that 1 MHz speed instead of the 2.8 MHz of the rest of memory. This has plagued Apple IIgs graphics developers since the beginning.
Fortunately, a number of hardware quirks of the Apple IIgs can be used in tandem to help ease the pain:
Shadowing allows bank $01 to be shadowed into bank $E1; that means that whatever is written into bank $01 is automatically mirrored into bank $E1 without slowing you down.
You can use the main/auxmem toggle softswitches ($C003 and $C005) to use bank $01 instead of bank $00 for the direct page and stack.
The stack and direct page can both be moved around within the bank they're located in by changing the values of the stack pointer and direct register.
Let's see how this works!
You probably know that the Apple IIgs has a single super high-resolution graphics buffer, located in bank $E1 from $2000-$9FFF. This SHR buffer consists of the pixel data, a set of scanline control bytes (SCBs) which describe the mode of each row of pixels (including which palette to use and whether they're using 320 or 640 mode), and 16 palettes.
That memory is slow. It's designed to support the 8-bit emulation of the Mega II chip, so writes into it are slowed down to 1 MHz whether you like it or not. There is a workaround, however. The clever people that designed the Apple IIgs included a technology called shadowing; this is the ability to have the hardware mirror writes to bank $01 into bank $E1. The writes into bank $01 execute at the computer's regular 2.8 MHz speed, which is obviously much better.
Shadowing of SHR memory in bank $01 into bank $E1 is controlled by bit 3 in the
SHADOW softswitch at $E0/C035. When 0, shadowing is enabled. When 1, it's disabled. Other bits in that softswitch control other aspects of shadowing, so be sure not to change those by mistake (use a read/modify/write operation sequence).
So the first step is to allocate the bank $01 SHR buffer. It has to be located at $01/2000-$01/9FFF or it won't work, for obvious reasons — shadowing mirrors writes to the same address in bank $E1.
Make sure you own the bank $01 SHR buffer! Either use
NewHandle to allocate it (be sure to set the flag that allows it to allocate special memory, since all of bank $01 is special) or, for GS/OS applications, set the auxtype bit which tells GS/OS to use shadowing so that GS/OS allocates it for you.
Bytes already located in the bank $01 SHR buffer, however, do not get copied over automatically. You have to write any bytes you want to be copied after shadowing is turned on. That means you either need to track exactly what's changed and redraw those bytes, or redraw the all or most of the frame every refresh cycle. Which you do is up to you (and likely depends on how much you expect to change each refresh).
You could of course use your typical
STA loops or similar to do the refresh, but there's a much, much faster way.
Stacking the deck: Direct page tricks
On the 6502 and 65C02, the zero page and stack are fixed in memory; the zero page is always located at $0000-$00FF, and the stack is always in $0100-$01FF. The 65816 changes all that. The direct page (previously known as the zero page) can be moved to any location in bank $00 by changing the value in the Direct register. This can be done using instructions such as
TCD (Transfer aCcumulator to Direct register), like this:
lda #$2000 tcd ; Set the direct page to $2000
Similarly, the 65816 allows you to move the stack by expanding the stack pointer register, previously an 8-bit offset from $0100, to be a 16-bit pointer within bank $00. You use instructions such as
TCS (Transfer aCcumulator to Stack pointer) to move the stack.
The nice thing about stack and direct page operations is that you can use instructions which use fewer cycles to access them. In fact, it gets even better, as we'll see shortly.
But how does this help us? The shadow SHR buffer is in bank $01, and the direct page and stack are trapped in bank $00. Right?
Not so fast!
The Apple IIgs was designed to be able to emulate an enhanced 8-bit Apple II; that is, an Apple II with at least 128K of memory. Because 8-bit Apple IIs can't access more than 64K directly, a mechanism called bank switching allowed you to select which 64K bank of memory you wanted to use by swapping them into the same 64K address space. This was done by using softswitches to toggle between main and "auxiliary" memory, often called "auxmem."
There is a set of softswitches for selecting whether you wish to read from auxmem or main memory, and write to auxmem or main memory:
$E0C002 equ RDMAINRAM ; Read from main memory $E0C003 equ RDCARDRAM ; Read from auxiliary memory $E0C004 equ WRMAINRAM ; Write to main memory $E0C005 equ WRCARDRAM ; Write to auxiliary memory
The Apple IIgs emulates this mechanism by using bank $00 as main memory and bank $01 as auxmem; writing to
WRCARDRAM causes bank $01 to take bank $00's place, so that any accesses to bank $00 actually access bank $01. Now things get interesting: by selecting auxiliary memory in this way, accesses to the stack and direct page actually access bank $01, which is where our SHR buffer is!
Now we have a way to use higher-performance direct page and stack instructions to read and write the SHR buffer.
Now we need to figure out the best way to use them.
Putting it together
The first thing you need to do is turn off shadowing. You don't want writes to the SHR buffer in bank $01 to be duplicated to bank $E1 until you're ready.
sep #$20 lda >SHADOW ; Get the current shadow register ora #$08 ; Turn on the "stop SHR shadowing" bit sta >SHADOW ; Save the updated value rep #$20
This code ensures shadowing is disabled while not changing the values of any of the other bits in the
SHADOW register (there are bits for controlling shadowing of various parts of bank $00; all we care about is the SHR buffer, though).
Now you should draw your graphics into the SHR buffer in bank $01, starting at $2000. You can either redraw the whole thing or, preferably, update just the areas that need to change. This doesn't affect what's on the screen, so it can be done in pieces or in strange ways that would look weird if immediately reflected on the screen. However you like.
This is the point where you decide when it's time to update the display. Maybe you do it immediately every time a screen update routine finishes running. Maybe you wait until the vertical scan has passed the first scan line you plan to refresh. Either way, the process is roughly the same. Wolfenstein 3D just draws, without worrying about the vertical scan, because it cares more about speed than about possible tearing effects caused by vertical refresh collisions.
When the time comes to refresh the display, the first step is to turn shadowing back on so that writes to the bank $01 SHR buffer are mirrored to bank $E1:
sep #$20 lda >SHADOW ; Get the current shadow register and #$F7 ; Turn off the "stop SHR shadowing" bit sta >SHADOW ; Save the updated value rep #$20
Now shadowing of the SHR buffer has been enabled. Our job now is to take the contents of the bank $01 buffer — at least the parts of it which have changed — and draw them right on top of themselves to update the bank $E1 buffer.
Think about it for a moment. The contents of the bank $01 buffer are only shadowed to bank $E1 when changed, but it contains the image we want to display already. So we just need to read the changed data and write it right back on top of itself to mirror it to the displayed screen. It sounds a little crazy, but only until you think about it a bit.
Since we're going to be fiddling with the stack and direct registers, we need to save those so we can restore them later. We also need to disable interrupts:
sei ; Disable interrupts tdc ; Copy direct register to accumulator sta savedDP ; Save it locally tsc ; Copy stack pointer to accumulator sta savedStack ; Save that too
Why did we disable interrupts? Because if bank $01 is occupying the space normally used by bank $00 when an interrupt fires, the interrupt handler will almost certainly blow up.
Imagine this scenario: AppleTalk is enabled, and the AppleTalk interrupt fires, causing AppleTalk code to get control of the system briefly. It restores its stack and direct page pointers so it can access the data it expects to access, but because bank $01 is now occupying the space normally taken by bank $00, it gets the wrong data (and writes over other programs' data), and things rapidly fall apart.
Now we complete the preparations by swapping bank $01 to replace bank $00:
sep #$20 sta >WRCARDRAM ; Writes to bank $00 go to bank $01 sta >RDCARDRAM ; Reads from bank $00 to go bank $01 rep #$20
From now on, all accesses to bank $00 actually access bank $01. We're going to use stack and direct page instructions exclusively, for maximum performance.
It's time to start drawing. We're going to draw the entire screen each frame for this example. It's what Wolf 3D does, because it's pretty typical for most or all of the screen to change, or at least enough of it that trying to update only parts of it wasn't worth the effort. But you might find ways to do better.
The first step is to set the Direct register to point to $2000, the first byte of the SHR buffer:
lda #$2000 tcd
Now we can use direct page instructions (which typically use one cycle less than their absolute equivalents) to access the first page (256 bytes) of the buffer. That alone sounds like a win, and it is, but we can do better.
The next step is to point the stack pointer at $20FF, which is the last byte in the first page of the SHR buffer:
clc adc #$00FF tcs
This takes the $2000 already in the accumulator, adds $FF, and stores the result ($20FF) into the stack pointer. We calculate the value because we'll be looping back up here later.
Now it's time to copy a page of screen data on top of itself, so that that page of data is shadowed to the main SHR buffer in bank $E1. By default, you think that you'll need a loop of reading a word, then writing a word, over and over. But it turns out that with our direct page and stack configured the way we have them, there's a way to combine reading and writing into a single instruction!
PEI (Push Effective Indirect) instruction isn't used as often as it should be. It fetches a word from the direct page and pushes it onto the stack in one smooth operation.
So consider this: our stack now starts at $20FF and works backward toward $2000 with each push. The direct page starts at $2000 and extends up to $20FF. That means they overlap, occupying exactly the same memory. So
PEI can be used to copy a value on top of itself in this space, if we use it just right. Here's how:
This one instruction takes the value located at offset $FE on the direct page (that is, at $01/20FE-20FF) and pushes it onto the stack. Since the stack pointer is at $20FF, the result is that the value is written on top of itself, using a two-byte, 6-cycle operation to refresh two bytes of the screen.
So all we need to do to copy the entire page over is to have a series of 128
PEIs in a row. We do this instead of using a loop because a loop just adds overhead, and every cycle counts when you're doing this — especially since interrupts are disabled, and we can only leave them disabled for so long. This code looks like this:
pei $FE pei $FC pei $FA pei $F8 ... pei $06 pei $04 pei $02 pei $00
Once you've finished copying the page, update the Direct register and Stack pointer (to $2100 and $21FF) and do it over and over, adding $0100 to each register every time a page finishes copying.
There are two tricks.
Trick 1: Periodically enable interrupts
First, you need to periodically switch bank $00 back into place and re-enable interrupts so that MIDISynth, GS/OS, and other users of interrupts have an opportunity to be serviced. The operating system cannot keep functioning properly if you don't allow it to periodically handle interrupts, and music and sound effects will not play correctly if their interrupts aren't processed.
Also, AppleTalk networks will be disconnected if interrupts are disabled for more than 104.167µsec — another reason to be careful with how long you leave interrupts disabled.
Wolfenstein 3D re-enables interrupts after every seventh page is copied), like this:
sep #$20 sta >RDMAINRAM ; Read from bank $00 sta >WRMAINRAM ; Write to bank $00 rep #$20 lda entryStack ; Retrieve the original stack pointer tcs ; Then restore the stack to there lda entryDP ; Then do the same thing for the direct page tcd cli ; Enable interrupts
As soon as that
CLI (CLear Interrupt disable) instruction is executed, any pending interrupts are processed before our code continues to execute. That means we can immediately disable interrupts again and restore our work environment to continue to copy the screen:
sei ; Disable interrupts sep #$20 sta >RDCARDRAM ; Read from bank $01 sta >WRCARDRAM ; Write to bank $01 rep #$20
Now all you have to do is restore the stack and direct page to where you left off and pick up where you left off. Keep going until you reach $9D00 (or $A000 if you need to update the palettes and SCBs). You can of course also stop sooner if you only need to update certain rows.
Trick 2: Insert NOPs
Due to the intricacies of how the 65816's cycle timing works, you can actually improve the performance of your PEI-slamming code by inserting a
NOP instruction after each 8th
PEI. There are some sources that say to put it after every 13th
PEI. It's unclear which is correct; Wolfenstein 3D does it every 8th one, however.
This technique takes some getting used to, and has a lot of ways you can mess it up (by failing to toggle a softswitch at the right point, usually, or by imbalance between the stack and direct page locations you're using). But once you get it down, it's among the fastest, if not the very fastest, ways to blit to the Apple IIgs screen using a back buffer.
The memory shadowing feature copies writes from $01/2000-9FFF to the corresponding location in bank $E1.
What you want to do looks like this:
- Perform computation for the current frame.
- Disable shadowing ($E0/C035 &= $F7).
- Render the frame into bank $01.
- Enable shadowing ($E0/C035 |= $08).
- Wait for the scan beam to reach a point where tearing won't be visible.
- Copy the frame from $01 to $E1.
The fun trick is in step 6. Using the extended-memory switches introduced on the 128K Apple //e ($E0/C003 and $E0/C005), you can map the memory from bank $01 into bank $00. If you map the stack and direct page on top of the graphics screen, you can use the 65816
PEI instruction to push a 16-bit value from the direct page to the stack, copying the data onto itself. With shadowing enabled, the data will be shadow-copied into the same area of bank $E1.
Make sure you have interrupts disabled while using the stack this way.