A Teensy 3.6 running as an Apple //e
I didn't know it was possible to have a first-run PCB that actually worked.
The silk screen label error is the only one I've found so far. The joystick axes are reversed, which I thought I was probably doing when I wired it up; I intentionally didn't bother checking. And I haven't verified the voltage divider on the analog input to check the battery level (it didn't work correctly in the original prototype, so maybe it's also not working here).
The Teensy is behind the LCD, as you can see - which means the MicroSD is much more accessible (it's no longer jammed up against the joystick).
I picked a random speaker from Adafruit that looked like it would be reasonable, and it's fairly loud, so I'm happy with that choice. It and the battery are now double-sided-taped to the back of the PCB.
Next up is some software cleanup. I left the software in an odd state; I was implementing real-time audio interrupts for better sound card support. To get that, I sacrificed video quality that I'd like to get back now. And there's at least one error that Jennifer found in the original code when she tried to compile it with a newer version of the Arduino environment...
Just about everything arrived in the last couple days. A couple of Teensy 3.6es came on Saturday; the Tall Dog Teensy 3.6 breakout board arrived today. (I decided that I'd like people to be able to buy those pre-assembled, if they're not up to the soldering, so we're trying a version that uses the adapter board. We'll see how that works.)
About 45 minutes to slowly mate the two...
And, bless my luck, the OSH Park PCBs also arrived today!
So far I've found only errors in the silk screen layer. The speaker and battery labels (on the back side of the PCB) are reversed; a few of the labels for the keyboard are a bit too big and are cut off oddly. The drill holes for the display, and the custom part I made for the joystick breakout board, line up perfectly. And the joystick appears to completely clear the display and Teensy, no fouling at all.
Quickly, then! Let's throw all the components on the PCB and see what it looks like...
Now, the Teensy is fairly tall; I'll need to raise up the display higher than normal to accommodate. So we'll have to wait a few more days: some 16mm standoffs and an extra tall 2x20 pin header are on their way from Adafruit. Being this close to New York, their parts arrive hella quickly, so maybe I'll be able to solder this all together on Thursday...
It's still a bit of a mess, and I have to do some coding to bring it in line with the latest schematic, but...
... it's assigned to a panel at Osh Park.
One interesting tidbit came up during the wiring cross-check - the display that Jennifer bought from eBay has its pixels in an odd order (something like 1 4 3 2 5 8 7 6).
With some fruitless digging in the datasheet and then more fruitful searching on the internet, we found a similar problem from someone else that was resolved with an initialization change; it traces back to one specific bit:
Weird, but here's the proof...
Anyway - since there are obviously at least two different versions of this display, I figured I'd throw in a configuration jumper on the first prototype board ("DISPSEL"). Easy enough to read a pin on startup and decide how to initialize based on that.
Next problem: while building out all of the key labels on the silkscreen layer, I found that I'd misplaced some of them (whoops); and that the connector for the display was rotated 180 degrees (double whoops). After fixing those, the autorouter couldn't route all the traces (triple whoops - I feel like I should have bought a lottery ticket).
So: J1 and J2 now exist to provide path for the LCD voltage. I also remember - vaguely, too many projects between then and now! - having seen the display flagging very early in the battery discharge cycle, probably because it expects 5v and I'm giving it whatever's coming off of the 18650 (nominally 3.7v, but up to 4.2v when fully charged). So this also gives me the chance to play with a boost circuit here, I suppose. Two birds killed with one stone?
Lastly - I had one more surprise hiding in my hand-wired prototype: an nRF24L01 board. I was toying with using a SLIP connection back to a machine in my office for some Internet connectivity... but have changed plans, substituting an ESP8266 ESP-01 module instead. :)
No idea when I'll get that coded or wired; also no idea if I'll get it to work. But it's there and if it does what I want, all the better...
Several people are interested in building these. Interested more than is probably healthy, which also describes me in my initial build for this thing too. And then there's the pile of hoops I'm jumping through to publish this and build a better prototype, which is *definitely* not healthy. :)
And step 1 of that would be to draw up the schematic.
I've not really used Eagle in years. I've made several small devices, used BatchPCB (when it existed) and OSH Park to manufacture them, and enjoyed the results. All except the part where EAGLE is designed for engineers, by engineers, without any serious thought about UI. It's truly one of the most awful UI experiences - second only, I think, to AutoCAD. So when I read that EAGLE had in fact been bought by AutoDesk, I knew I was in for a world of hurt.
Well, maybe I didn't give them enough credit. They fixed a really annoying UI problem with trackpad zooming from Eagle 7. So that's an improvement. And many of the icons no longer look like they were drawn in MacPaint in 1990. But then we get in to this modern trend of "let's charge a monthly subscription fee! That's better for the consumer!" bullshit.
I want to buy a piece of software and use it until it no longer meets my needs. I mean that either there's a new version that does something better, which I explicitly want; or it doesn't run under the OS that I'm running on my machine, because I decided to upgrade my OS. I'm still using Lightroom 4, for example. It meets my needs, it runs fine on my machine, and I have no interest in upgrading. Yes, there are new features in Lightroom 5 that are fantastic. Yes, Lightroom 6 probably improves on that a lot. I don't want those things yet.
So why, then, would I want to pay a monthly subscription to be able to use software? What if I'm on vacation and decide I want to noodle around with schematics while I'm on a plane and disconnected from the Internet? What if my interests have bounced away from software X this month, and to software Y? How much of my life do I now need to spend managing which licenses I've paid for this month, and what that does to my saved files when I accidentally open them or forget to renew a subscription?
Or a more real possibility: what if I'm up at 3 AM and decide I want to design a PCB that's roughly 7" x 4"? I need to buy an upgraded license for Eagle 8, and then WAIT THREE DAYS FOR THEM TO FIGURE OUT WHY MY CLOUD ACCOUNT DOESN'T SHOW THE UPGRADED LICENSE.
Thanks, chuckleheads. I appreciate that your cloud service saved me absolutely no time. Your model is not what I want, and is not better for me. It's more frustrating.
But at least it's going to save me some money. Maybe.
I was ready to shell out the Pro license fee for a full upgrade of EAGLE. But now I can pay $70 for a single month, design this thing, and drop the license, reverting to the educational license that I get because I work at a University. Maybe. We'll see if they're able to do that without the license manager barfing again.
Okay, rant over - I've got a first draft of the Aiie! schematic. It's a doozy; I've drawn it without using bus simplifications...
The Teensy 3.6 has been replaced with the Teensy 3.6 on a breakout board from Tall Dog on Tindie. This was something that a very devoted Apple fan brought to my attention because she'd found them and was planning to use one for her own build. (Thanks, Jennifer!) It saves folks the trouble of soldering the pins on the back of the Teensy, at the cost of an extra $15 or so and a serious lift in board space required. Meh, good enough for now.
There are probably mistakes in my drawing. I haven't yet validated this. I'm pretty sure I mixed up the joystick X and Y axes, at least.
The aforementioned Jennifer is wiring up one by hand; assuming she gets good results, I'll submit the PCB to OSH Park and do another build myself to see how it works.
At a cost of $125. Just for the PCBs.
Yep, this board is not a teeny little thing. It costs a bundle to have fabricated as a one-off....Read more »
This whole thing was a matter of "I can build that with what I've got". And I've got a *lot* of stuff, which explains why there's a currently unused nRF24L01 soldered in there. What else do I have lying around that could become part of this project? Let's see... hey! Hows'bout this guy?
A number of years ago, I bought this thermal printer (probably from SparkFun) as one of those "that's interesting and I don't know what to use it for now, but it'll probably disappear off the market in 12 seconds and I'll regret not having bought one" kinds of purchases. My son had a friend over one weekend, and I briefly used it as part of a scavenger hunt: they found an RFID card, that they had to swipe across the timey-wimey box that my then 9-ish-year-old son used as a time machine. It had an Arduino Uno in it with an RFID reader; he had a key card that he needed to "start" the time machine, before he played with the buttons and dials and made noise and lights with analog dials that went up and down. When they found the clue that gave them a blank RFID card, he knew exactly what to do with it - he swiped it on the timey-wimey machine, which had a mysterious black box connected to it, and BBZZZZZT out came receipt tape with their next clue printed on it!
And since that magnificent performance, this guy has sat in a drawer. With the timey-wimey machine that he stopped playing with. Not coincidentally, they're both in the "partial projects" drawer that I occasionally go to in order to scavenge parts.
Which brings me to the actual story. Can I glom this guy on to my Apple //e? Back in the day, I had a printer, of course; the Epson FX-80. (Apparently you can still buy ribbons for them on Amazon. Holy. Crap.) What if I made this a virtual FX-80, to go with my Virtual Apple?
Thinking of the physical hardware: first you'd need to add a printer card. That's easy enough - add another object that inherits from the Slot class, which requires a few methods:
virtual void Reset();
Boringly called when we want to re-initialize the card to its boot-up state. We can deal with that later.
virtual void loadROM(uint8_t *toWhere);
Used to load its ROM image in to the MMU's memory on boot or reset. So what ROM do we load? I *didn't* grab the ROM from my printer card. (I don't even remember for certain what it was. I think it was probably a Grappler+?) Which leads me on the hunt for a printer rom. Fortunately for us, the Apple 2 Documentation Project exists! Buried in there are ROM images for Apple's original (1977) Parallel Card. I think it's the one named "Parallel Mode". The ROM image is all of 256 bytes.
Let that sink in a minute: Woz managed to pack the parallel port interface that became the base standard for printing on the Apple II in 256 bytes. I double dog dare you to write anything actually useful in 256 bytes today. Even the Hackaday challenge was for 1k!
Back to the task at hand:
virtual uint8_t readSwitches(uint8_t s); virtual void writeSwitches(uint8_t s, uint8_t v);
These handle the reads and writes to the mapped memory registers ("switches") that this I/O device uses. For a first pass, I'm going to ignore reading; any read will just return 0xFF. And for writing, it looks like the parallel card sends commands to us at memory address 0xC100 (slot 1, switch 00).
Right! We have a ROM. We have a ParallelCard in a Slot. Time for a virtual printer - fx80.cpp. This is fairly complicated, and does a lot of "take that input, switch in to or out of command modes based on the input, and do the right thing with it". And the easiest thing to have it do is print graphics.
Why is that easier than text? Simple: I want the text to look like the FX-80 text. And for that, I'm gonna need to hand-code their font, one dot at a time. Which is entirely possible, thankfully, because they printed the whole damn thing in a manual. (Aside: Epson, themselves, have the manuals online! What an amazing thing in this day and age.)
Anyway: some manual bitmap coding later, and we have at least...Read more »
(This post refers to git commit 3af0b916d7481305979181e1c307ef40e1b46f19.)
Finally! Here's the important part: all of the hardware model for the Teensy. Well, most of it. There's something I haven't put in quite yet that I'll get to in the next log. :)
This is, for the most part, very straightforward. All of this stuff is in the teensy/ directory of the project.
The file teensy.ino is the glue, just like aiie.cpp was the glue for the Mac-based emulator. It connects together the MicroSD card, keyboard, display, joystick, and Apple //e VM. It's responsible for running instructions on the CPU at the right time. And it's a bit messy.
Let's start down in the virtual 65c02 CPU again. With the Mac version of the emulator, aiie-opencv runs two concurrent threads - one for the CPU and one for the display, basically. The same division happens on the Teensy; there's a function runCPU() that performs some work on the CPU. (Let's assume that's one instruction for now.) It can't do exactly what the Mac version does - which is to run an instruction and then nanosleep() until it's time for the next instruction. The Teensy variant is running from a timer interrupt handler; it has to return, so that the main loop() can continue to redraw the LCD screen. So instead, the Teensy code keep track of when the next instruction *should* run, in microseconds. When the runCPU() function is called, it checks to see if it's time to run an instruction; and if so, it does. If not, it simply returns.
But there's a lot of overhead in calling the virtual 65c02 to perform one instruction. We have several function calls, each of which has to save and restore register values, in addition to the memory jumps and returns. If you run the CPU flat-out in one function, it performs much better than if you call it one step() at a time. And, indeed, if we call one step() at a time we have none of Teensy's CPU time left to draw the screen. We're running at a fraction of the speed of the original Apple //e. Which means that we need to compromise.
Instead of calling the virtual CPU one step at a time, we tell it to execute a few instructions before it returns. The "Run(24)" call tells it to execute enough instructions to take up at least 24 clock cycles before returning. (The number comes from experimentation; the average time used per instruction is about 3 cycles, and this happens to be the largest multiple of 3 that didn't have other unwanted effects.)
Couple that with Timer1. This drives the CPU. Timer1 has a resolution of 1 microsecond, which is *just a little* too slow for us; the Apple //e clock is actually 0.97752 microseconds. And it's actually immaterial, because of the Run(24): we're already sort of driving in a traffic jam: we move a little, then stop. Then move. Then stop. The trick is to look at this from high enough above that it looks like everyone is moving, just very slowly; we want to be correct *on average*.
That also means that we don't care that Timer1 has a maximum resolution of 1 microsecond; in fact, we can back off a little. And I did. It's called every 3 microseconds, with little to no visible impact. That leaves extra time for the LCD to draw, and in practice the current version can draw about 26 FPS reliably.
It only gets that speed because of optimizations, though. Using the stock LCD libraries, we'll only get a fraction of it; the libraries try their best to be *accurate*. Which means they make very few assumptions about how you're likely to draw. Want to draw a pixel on the screen? Sure! We'll set the cursor position, set the ram position, put you in write mode, and draw the pixel. Want to draw the next pixel over? Same thing. Only... well, the LCD automatically incremented the memory address, so all you really had to do was draw the actual pixel. All that cursor/ram/mode nonsense is superfluous. And in teensy-display.cpp, you'll find routines that are probably only useful to this project: they assume that the LCD is going to be used the way that *I'm*...Read more »
(This post refers to git commit 8e155646c9843c095ee4733481913707f49bfe1d.)
This one is kinda big, and I'm going to just give a high-level overview. You can go dig through the weeds yourself, or send me questions that I'll probably ignore for far too long before ignoring you. (Sorry about that, it's not you.)
The virtual machine architecture is broken in half - the virtual and physical pieces. There's the root VM object (vm.h), which ties together the MMU, virtual keyboard, and virtual display.
Then there are the physical interfaces, which aren't as well organized. They exist as globals in globals.cpp:
FileManager *g_filemanager = NULL; PhysicalDisplay *g_display = NULL; PhysicalKeyboard *g_keyboard = NULL; PhysicalSpeaker *g_speaker = NULL; PhysicalPaddles *g_paddles = NULL;
There are the two globals that point to the VM and the virtual CPU:
Cpu *g_cpu = NULL; VM *g_vm = NULL;
And there are two global configuration values that probably belong in some sort of Prefs class:
int16_t g_volume; uint8_t g_displayType;
I've done most of the testing on my Mac, rather than on the device itself - it's easier, faster, and has a full debugger - so there's a Makefile here that will generate an emulator under macOS 10.11.6 with Homebrew and OpenCV installed:
$ make opencv $ ./aiie-opencv /path/to/disk.dsk
As the name implies, this requires that OpenCV is installed and in /usr/local/lib. I've done that with Homebrew like this:
$ brew install opencv
"Why OpenCV?" you might ask. Well, it's just because I had code from another project lying around that directly manipulated OpenCV bitmap data. It's functional, and the Mac build is only about functional testing (for me). I just needed a window that I could draw in!
A little bit about the file structure: the code is separated in to three subdirectories. There's 'util', which holds the 6502 functional test harness and the script to generate the ROM headers; the 'apple' directory, which contains the Apple //e VM code; and the 'opencv' directory, which holds all of the Mac-specific code. The remainder of the code is splayed out in the project root: the CPU, File manager, base Physical and Virtual object definitions, etc.
When the main program starts - in opencv/aiie.cpp - main() creates the physical and virtual objects (a DummySpeaker, OpenCVFileManager, OpenCVDisplay, openCVPaddles, Cpu, AppleVM, and OpenCVKeyboard); wires them together; and then starts them running.
The 65C02 CPU runs in a dedicated thread (cpu_thread) which executes at least 24 cycles of processor time; calls some maintenance functions to keep the various pieces of virtual hardware working; and then calculates how long it needs to sleep, based on how many CPU cycles really executed and what the clock time was when it started.
It's this sleep that keeps this running at 1023 kHz. Take out the nanosleep() call and this will run flat-out as fast as it can. Which, on modern hardware, is ridiculously fast. And if you want it to run at some other speed - perhaps the 8MHz that the //c could brag about - you'd change CYCLES_PER_SECOND from "1023000UL" to "8184000UL" (or whatever speed you want).
While it's running, the VM will spit out lines of text like this:
hit: 2156381; miss: 0; pct: 0.000000 hit: 4224999; miss: 0; pct: 0.000000 hit: 6233853; miss: 0; pct: 0.000000
If you ever see "miss" become non-zero, it means it's taking too long to run the instructions and isn't sleeping at all. Your hardware is too slow for this implementation, at this speed. (I'd be surprised to see that happen.)
There's no sound here - as you might have guessed from the name "DummySpeaker". And there's no support for the joystick buttons. But everything else works (including joystick position, which is emulated by cursor position within the window).
Next up: time to get it running on the Teensy!
(This may take a little while; now that I'm reasonably happy with the code, I'm refactoring it while I describe it. I've just created a public github repo to hold it all. This entry refers to git commit 85a97abe13528bdf35c525f42aea7ffb003eafff.)
Emulating a CPU isn't difficult. It's mostly a matter of interpreting the next instruction, one instruction at a time, over and over again. The 65C02 is pretty straightforward: it has three 8-bit registers named a, x, and y; an 8-bit stack pointer, sp; an 8-bit status register that holds flags about the current state; and a 16-bit program counter (pc) which has the address of memory that's currently being executed.
One step of the CPU means getting the byte of memory at the PC; executing it so that it changes A, X, Y, STATUS, SP, and PC depending on the instruction; and incrementing the PC to the start of the next instruction.
All of that is in step().
The CPU can address 16 bits of memory. An easy version of the CPU would just have a 64k array that it reads from and writes to. But the memory on the Apple II is not that straightforward. If, for example, you read from memory address $C000 you'll get whatever key is pressed. If you write to anything between $C0C0 and $C0CF, you're interacting with whatever's in slot 6. All of which means we need an intermediate Memory Management Unit to broker all reads and writes. And that's what we now have: a CPU with an MMU model.
Pulling that together with a simple test harness lets us check that the CPU works properly. There's a great utility written by Klaus Dormann that tests all of the 6502's functionality. There are precompiled binaries in there that I've dropped in to the test harness that prove it works correctly:
$ make test g++ -Wall -I .. -I . -O3 -DBASICTEST cpu.cpp util/testharness.cpp -o testharness.basic g++ -Wall -I .. -I . -O3 -DVERBOSETEST cpu.cpp util/testharness.cpp -o testharness.verbose g++ -Wall -I .. -I . -O3 -DEXTENDEDTEST cpu.cpp util/testharness.cpp -o testharness.extended Start test 2 Start test 3 Start test 4 Start test 5 Start test 6 Start test 7 Start test 8 Start test 9 Start test 10 Start test 11 Start test 12 Start test 13 Start test 14 Start test 15 Start test 16 Start test 17 Start test 18 Start test 19 Start test 20 Start test 21 Start test 22 Start test 23 Start test 24 Start test 25 Start test 26 Start test 27 Start test 28 Start test 29 Start test 30 Start test 31 Start test 32 Start test 33 Start test 34 Start test 35 Start test 36 Start test 37 Start test 38 Start test 39 Start test 40 Start test 41 Start test 42 Start test 240 11 seconds Ending PC: 0x3399And that's success!
If you go take a look at the Teensy 3.6 pinout, you'll find that it has a bajillion I/O pins.
When I first saw all those pins, I thought something along the lines of "that's great, but it's gonna be a nightmare if I ever actually need all of those." I wasn't entirely wrong, but it's also a godsend that they're all available.
The display eats up all of ports C and D - pins 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22, and 23. It also needs its RS (16), WR (17), CS (18), and RST (19) wired up. 20 pins down.
The keyboard needs 13 columns wired up - pins 0, 1, 3, 4, 24, 25, 26, 27, 28, 29, 30, 31, and 32; and 5 rows - pins 33, 34, 35, 36, and 37.
That just leaves digital pins 38 and 39 unused.
39 I'm using for the reset/menu button, which I want to be separate from the rest of the keyboard. Eventually I'd like this to have some interrupt wired up. Or something. Seems like a good idea.
Pin 38, aka A19, is the battery input. Since the Teensy 3.6 isn't 5v tolerant on its inputs, and Li-Ion batteries go up to 4.2v when fully charged, a couple of resistors form a divider network to safely provide input here.
And now we're out of pins, but not out of peripherals to connect. What about the speaker?
Fortunately there are two 12-bit digital-to-analog pins! Ping A21 and A22, aka DAC0 and DAC1. I'm using DAC0. And now it feels like we're on borrowed time, having found an "extra" pin.
But we still have the joystick, and we're forced to decide which compromise to make.
The joystick needs two analog inputs. A22 is output-only. I could move something there as an output and reclaim one pin; and then move the reset/menu button in to the keyboard matrix, reclaiming another. I'm not liking that plan, but it's possible.
Or, we could look at the back side of that Teensy 3.6 pinout card...
Look at all the bonus pins! All of those pads, just sitting and waiting for an insane person to try to do something like, well, maybe this:
There were some casualties along the way. Because I was soldering in these pins after having put the other headers on the Teensy, there wasn't really enough room for the soldering iron. Some bits of plastic were accidentally melted; the pad for pin 54 was ripped off. But now we have a new lease on life! Extra pins everywhere.
The joystick gets A23 and A24. (I had originally put it on A10 and A11, but while troubleshooting the bad joystick problems I moved it.) And now we get in to insane-overtime-bonus-hardware-land.
I'd like to be able to interface this with, well, stuff. I have no idea what stuff. Or how. But I know that, when everyone was raving about the nrf24l01 last year, I bought two of them and stashed them to play with some day. So one of them goes in on pins 40, 41, 42, 51, 52, and 53. And a quick press-insert header sneaks under the edge of the LCD, giving me access to ground and pins 56 and 57.
AND WE STILL HAVE ROOM FOR MORE. The Teensy 3.6 is a beast. Nicely done, Paul!
But this all comes at a price. The second board, and standoffs, were added to protect this:
... so I took the botched first board, where I thought I was going to use that LCD interface - but found it unnecessary, threw the board in the trash and started over, and then rescued the trashed board when I needed a backplate. Because, y'know, nothing goes to waste? Or something. I figure it's good enough until I figure out what kind of case this will go in. (I'm not entirely sure that I ever will. It'll probably live its life like this, case-less.)
Two last details of the hardware: there's a backup CR2032 battery hiding under the LCD, for the Teensy's built-in clock; and I replaced the 1000mAh lithium-ion battery with a 3000mAh removable 18650 cell. I also ditched the USB charger, opting for a removable battery. Again, this isn't quite perfect; the battery holder is about 21mm wide, while the standoffs are 20mm. Add in the height of the solder under the keyboard and it *really* doesn't quite fit. So the standoffs at that end aren't tightened down all the... Read more »
Continuing to ignore the software for a moment: time came to wire up the keyboard.
This turns out to be very straightforward, if a bit pin-intensive. I wanted all of the keys on an Apple //e keyboard. The joystick buttons are the same as the open- and closed-apple keys; those are to the left of the teensy. The rest are all right there: 13 columns and 5 rows. There's also a reset/menu button that's in the upper-right, unfortunately just under the LCD (it seemed like a good idea at the time).
The Apple //e couldn't handle multiple simultaneous keypresses. There was a dedicated processor for the keyboard, which loaded data in to the data bus for one and only one key at a time. But I don't know what else I'll do with this hardware, so I'd like to at least have it capable of multiple keypresses, even if the virtual Apple //e I'm building won't be able to take advantage of it.
The classical way to build such a keyboard would involve a lot of diodes. I had about 30 1n917s and 1n1418s that I could use, but I'd need about 65. I actually bought 100 more 1n917s, getting ready to solder them all in. And then I found the Arduino matrix Keypad library, which does something very clever! It uses the tri-state nature of Arduino I/O pins to scan the rows and columns to figure out what's pressed. Because it's pulsing each row/column, it doesn't need the diodes to separate the signals. Bonus - saved me a bunch of soldering time! Those diodes go back in the box for the next project that needs them, and I can safely say that I've still built this thing out of the parts in my house.
For a little while, at least. Until I broke the joystick.
The picture you see above has the cap missing from the joystick. That's not the problem. I'd actually lost the cap while it was on the previous project it had been part of; the joystick was still functional. And it made it in to this project fully functional for a while. Then one of the two axes started giving me garbage. Resoldering made no difference, and I bought a replacement from Adafruit... just to have the second one do the same thing after about a day.
Harrumph. Guess I need to find a better joystick.
Unfortunately, by this point I've kind of painted myself into a corner. The design assumes that the joystick fits in that space. I've now soldered the display in, so I can't easily move it up to buy some more. And I can only find two other joysticks that will potentially fit. Both, fortunately, carried by Adafruit.
They look pretty much the same: the Parallax 2-axis joystick and the Analog 2-axis Thumb Joystick with Select Button + Breakout Board. I bought one of each, and when they arrived, there were two issues.
First: the thumb joystick just doesn't fit. It hits the teensy on the left. It hits the LCD just above it. But you can take the joystick piece off, so I'll worry about that later.
Second: neither of the joystick bases fits as-is. The first has an adapter board soldered on to it, and the second has a button that hangs off the right side (making it too wide to fit on my board).
Rather than cutting the button off of the second and re-working its mechanics, I opted to desolder the first from its adapter board.
Which leaves the joystick cap problem: a little Dremel work to remove its base, and things fit pretty well!
"What about the MicroSD card?" I hear you cry. I got lucky here: the card - which you may be able to just amke out to the left of the joystick - *just* clears the joystick base. It's possible to get it in and out, but it's not something I want to have to do often.
The joystick's rotation is still slightly fouled by the LCD when trying to move it to the upper left corner. But it's manageable. If I were starting over, I would move the LCD up a hair; and the Teensy would probably go under the LCD, vertically, with the SD card popping out the top. Which would mean hard-mounting the teensy instead of leaving it socketed...