Close

Entry 24: Look at these 53760 pixels that the world doesn't want you to see!

A project log for Aiie! - an embedded Apple //e emulator

A Teensy 4.1 running as an Apple //e

jorj-bauerJorj Bauer 01/22/2022 at 06:500 Comments

In 2021, a friend of mine gave me an Apple //e that he'd had sitting in a garage for years. He'd rescued it from a place where another one of my friends had been working, probably around 1996. I've spent a few months cleaning and fixing it up; part of that effort lead me to build the Apple ProFile 10MB Hard Drive reader. It's also gotten me playing Nor Archaist - which my wife bought me for Christmas 2020 - on the actual //e.

But that's not what I'm here to write about. I'm here to write about how all of this pushed me back to working on Aiie!

Just as I was thinking about how the //e was going to come back together, someone reached out to me on Twitter with questions about their own Aiie v9 build. Some of the components are no longer avaialble. I never listed why I picked the voltage regulator circuit I'd used (because it's a 1A boost). The PCB pads for the battery aren't labeled (J6 is +B and J5 is GND, but I'd left it flexible for the boost circuit). The version of HDDRVR.BIN (from AppleWin) has changed. The parallel card ROM is one of a pair, so if you have the actual card, it's not clear which one to dump (it's the Apple Parallel ROM, not the Centronix ROM).

This all got us talking about what else I'd forgotten to finish (sigh, woz disk support; Mockingboard emulation; WiFi). And then we started talking about what else we could do with a new revision of the hardware. Design a case, maybe. Update the parts list. Integrate a charger.

And update the display.

Now, most of that was on my roadmap... but I'd convinced myself to forget completely about the display. It's a hack that I'd optimized and considered "done."

The display on the Aiie! v9 is a 320x240 SPI display (the ILI9341). It's the second display I've used for this project. The original was a parallel interface that required a lot of CPU time to drive; I think I managed to get it up around 12 frames per second. The SPI interface for the ILI9341 not only uses fewer pins, but it can also be run directly from the Teensy's eDMA (extended? expanded? direct memory access) hardware, directly sending the data out the SPI bus without the program manually doing the work. eDMA does a block transfer; when it ends, it automatically starts another one. The frame rate went through the roof. I think I saw it up around 40fps... where anything over 30 is overkill. (Hmmm... the black magic of the ARM IMXRT 1062 eDMA system would be a good topic for another log entry...)

Fine, that explains why I chose an SPI bus driven display. But why is the display resolution 320x240?

The Apple II video is really low resolution. The plain text screens are 40x24 characters, where each character is 7 pixels wide and 8 pixels tall - resulting in a 280x192 display. Lo-res graphics chop each character vertically in half, giving you 40x48 blobs that use 280x192 pixels. Hi-res graphics are, not surprisingly, 280x192 pixels. Fits fine in a 320x240 display, no problem! They're cheap, one's for sale right at PJRC along side the Teensy, and it's got a well supported driver that's been optimized by the Teensy community. It's a slam dunk

But with the //e's 80-column card, Apple made it weird. (I know, that's not a big stretch. The whole machine is built around engineering miracles, which is part of what I find so endearing.)

In 80-column mode, the horizontal resolution doubles but the vertical does not. Basically data gets shoveled out the NTSC (or PAL) generator twice as fast so it's twice as dense - but the number of scan lines aren't affected. So you wind up with the really awkward 560x192 pixel size for 80-column text, double-low-resolution, and double-high-resolution graphics.

So I wrote the core of Aiie! to support 560x192. There are three builds of the core code - one for SDL (which is what I primarily use for development under MacOS); one for a Linux framebuffer (which I've used in passing on a RaspPi Zero as a toy); and the Teensy build for my custom hardware. Under SDL and the framebuffer, I'm using 800x600 (the same aspect ratio as 320x240) and it can directly draw the 560x192 Apple pixels. But on the Teensy I had to be ... clever.

// This was called with the expectation that it can draw every one of                                                                                   
// the 560x192 pixels that could be addressed. If TEENSYDISPLAY_SCALE                                                                                   
// is 1, then we have half of that horizontal resolution - so we need                                                                                   
// to be creative and blend neighboring pixels together.                                                                                                
void TeensyDisplay::cachePixel(uint16_t x, uint16_t y, uint8_t color)
{
#if TEENSYDISPLAY_SCALE == 1
  // This is the case where we need to blend together neighboring                                                                                       
  // pixels, because we don't have enough physical screen resoultion.                                                                                   
  if (x&1) {
    uint16_t origColor = dmaBuffer[y+SCREENINSET_Y][(x>>1)*TEENSYDISPLAY_SCALE+SCREENINSET_X];
    uint16_t newColor = (uint16_t) loresPixelColors[color];
    if (g_displayType == m_blackAndWhite) {
      // There are four reasonable decisions here: if either pixel                                                                                      
      // *was* on, then it's on; if both pixels *were* on, then it's                                                                                    
      // on; and if the blended value of the two pixels were on, then                                                                                   
      // it's on; or if the blended value of the two is above some                                                                                      
      // certain overall brightness, then it's on. This is the last of                                                                                  
      // those - where the brightness cutoff is defined in the bios as                                                                                  
      // g_luminanceCutoff.                                                                                                                             
      uint16_t blendedColor = blendColors(origColor, newColor);
      uint16_t luminance = luminanceFromRGB(_565toR(blendedColor),
                                            _565toG(blendedColor),
                                            _565toB(blendedColor));
      cacheDoubleWidePixel(x>>1,y,(uint16_t)((luminance >= g_luminanceCutoff) ? 0xFFFF : 0x0000));
    } else {
      cacheDoubleWidePixel(x>>1,y,color);
      // Else if it's black, we leave whatever was in the other pixel.                                                                                  
    }
  } else {
    // The even pixels always draw.                                                                                                                     
    cacheDoubleWidePixel(x>>1,y,color);
  }
#else
  // we have enough resolution to show all the pixels, so just do it                                                                                    
  x = (x * TEENSYDISPLAY_SCALE)/2;
  for (int yoff=0; yoff<TEENSYDISPLAY_SCALE; yoff++) {
    for (int xoff=0; xoff<TEENSYDISPLAY_SCALE; xoff++) {
      dmaBuffer[y*TEENSYDISPLAY_SCALE+yoff+SCREENINSET_Y][x+xoff+SCREENINSET_X] = color;
    }
  }
#endif
}

If you look at the #if/#else/#endif, you'll see the simple case at the bottom (which is basically what the SDL and Framebuffer code do) versus what the Teensy code has to deal with for a smaller display. There are 3 different behaviors (and one that I've since removed). First: every even pixel gets drawn to the display, always. With just this piece, you can tell that there are letters in 80-column mode but it looks pretty awful.

Then the second is the luminance shader, if the user has picked the black-and-white display mode. This inspects both even and odd pixels, and decides if the combination are over or under a threshold to show a single dot. For black text on white, this looks really good; but for white text on black, it's really illegible... look at the quality of the "a" in the screen versus the rest of the text.

There was a third shader I'd built, where if either the even or odd pixels had any non-black value, then it would draw the pixel. In white-on-black text mode that looks substantially better than either of the above, but it does really badly in black-on-white text (look at that inverted 'a', it's just 3 dots now) and DHGR, so I compromised by using the luminance shader (which is equally bad in all cases).

So... yeah, I've built hacks to make it kinda work, but it's ugly and wrong; and while I can build a better antialiaser (to shade the result pixel better based on the left and right partners), I can't really make it right without switching to a higher resolution display.

It turns out that there are very few 800x600 displays available for microcontrollers. There are also a few 800x480 displays, which also works - the vertical axis will need doubled pixels for either 800x600 or 800x480, needing a minimum display resolution of 560x384. But they're all physically larger; and they are generally separate from the driver boards rather than nice integrated packages like the ILI9341. So we'll have hardware and software work to do - starting with a new proof of concept!

Discussions