Raspberry Pi Pico 15.6 KHz analog RGB to VGA upscaler (part 1? POC? WIP?)

My Hitachi MB-H2 MSX machine has an analog RGB port that produces a 15.6 KHz CSYNC (combined horizontal and vertical) signal and analog voltages indicating how red, green or yellow things are.

I recently unearthed my old LCD from 2006 or so and decided to see if I could get it to sync if I just massaged the CSYNC signal a bit to bring it to TTL levels and connected a VGA cable.

(Technical details: when you connect a VGA cable to a monitor that is powered on, you will often first of all see a message like “Cable not connected”. To get past that problem, you first have to ground a certain pin on the VGA connector. I found that female Dupont connectors fit reasonably well on male VGA connectors so I just used a cable with female Dupont connectors on both ends to connect the two relevant pins. I’m not sure if it’s the same pin on all monitors. You can find the pin by looking for a pin that should be GND according to the VGA pinout but actually has some voltage on it. Don’t blame me if you break your Dupont connectors by following this advice.)

Unfortunately, that didn’t work. I got “Input not supported”, and I am reasonably sure that is because my monitor doesn’t support 15 KHz signals. Aw, why’d I even bother taking it out of storage?

So what do we do… Well there is this library (PicoVGA) that produces VGA signals using the Raspberry Pi Pico’s PIOs. Raspberry Pi Picos are extremely cheap, just about 600 yen per piece where I am.

Yes, this is animated and super smooth.

Damn, I’ve seen this in videos, but seeing this in real life, a tiny, puny microcontroller generating fricking VGA signals! Amazing. Just last year I was playing around with monochrome composite output on an Arduino Nano, and even that was super impressive to me! (Cue people reading this 20 years in the future and laughing at the silly dude with the retro microcontroller from year-of-the-pandemic 2020. I’m sure microcontrollers in the 2040s will have 32 cores and dozens of pins with built-in 1 GHz DACs and ADCs and mains voltage tolerance, and will be able to generate a couple streams of 4K video ;D)

Some boring technical notes I took before embarking on the project, feel free to skip this section

Is the Pico’s VGA library magic? Yes, definitely. Can we add our own magic to simultaneously capture video and output it via the VGA library?
It sure looks like it! Why?

  • The Pico has two CPU cores, and the VGA library uses just one of them, the second core
    • Dual-core microcontroller, that’s craziness
  • We may be able to use the second core a little bit anyway (“If the second core is not very busy (e.g. when displaying 8-bit graphics that are simply transferred using DMA transfer), it can also be used for the main program work.”)
    • We will indeed be working with 8-bit graphics simply transferred using DMA
  • The Pico has two PIO controllers, and the VGA library uses just one (“The display of the image by the PicoVGA library is performed by the PIO processor controller. PIO0 is used. The other controller, PIO1, is unused and can be used for other purposes.”)

However:

  • We possibly won’t be able to use DMA all that much (“Care must also be taken when using DMA transfer. DMA is used to transfer data to the PIO. Although the transfer uses a FIFO cache, using a different DMA channel may cause the render DMA channel to be delayed and thus cause the video to drop out. A DMA overload can occur, for example, when a large block of data in RAM is transferred quickly. However, the biggest load is the DMA transfer of data from flash memory. In this case, the DMA channel waits for data to be read from flash via QSPI and thus blocks the DMA render channel.”)
    • If we use PIO and DMA for capturing video-in, we might run into trouble there
    • However, using DMA to capture and another DMA transfer to transfer the data to VGA out sounds somewhat inefficient; maybe it’s possible to directly transfer from capture PIO to VGA PIO? Would require modifications to the VGA library, which doesn’t sound so great right now (we didn’t do this)

That said, it’s likely that capturing without the use of PIO would be fast enough, generally speaking.
The “pixel clock” for a 320×200 @ 60 Hz signal is between 4.944 and 6 MHz according to https://tomverbeure.github.io/video_timings_calculator (select 320×200 / 60 in the drop-down menu), depending on some kind of mode that I don’t know anything about.
According to our oscilloscope capture of a single pixel on one of the color channels (DS1Z_QuickPrint22.png), we get about 5.102 MHz. Let’s take that value. We’ll hopefully be able to calculate the exact value at some point. (Yeah, the TMS59918A/TMS59928A/TMS59929A datasheet actually (almost) mentions the exact value! “The VDP is designed to operate with a 10.738635 (± 0.005) MHz crystal”, “This master clock is divided by two to generate the pixel clock (5.3 MHz)”. So it’s 5.3693175 MHz, thank you very much.)

This means that we have to be able to capture at exactly that frequency. From our previous experimental logic analyzer (which doesn’t use PIO) we were more than capable of capturing everything going on with our Z80 CPU — we had multiple samples of every single state the CPU happened to be in, and the CPU ran at 3.58 MHz. (However, if the VGA library chooses to set the CPU to use a lower clock frequency, we may run into problems. It’s possible to prevent the library from adjusting the clock frequency, but maybe that will impact image quality.) The main part of the code looked like this:

for (i = 0; i < LOGIC_BUFFER_LEN; i++) {
logic_buffer[i] = gpio_get_all() & ALL_REGULAR_GPIO_PINS;
}

To capture video, we’d like to post-process our capture just a little bit, to convert it to 3-3-2 RGB. Or we could post-process our capture during VSYNC, but that would be a rather tight fit, with only 1.2 ms to work with. (Actually, our signal’s VSYNC pulse is even shorter than that, but there’s nothing on the RGB pins for a while before and after that.)

So our loop might look like this. (Note, the code I ended up writing looks reasonably similar to this, which is why I’m including this here.)

for (x = 0; x < 320; x++) {
    pixel = gpio_get_all();
    red = msb_table_inverted[((pixel & R_MASK) >> R_SHIFT) << R_SHIFT];
    green = msb_table_inverted[((pixel & G_MASK) >> G_SHIFT) << G_SHIFT];
    blue = msb_table_inverted[((pixel & B_MASK) >> B_SHIFT) << B_SHIFT];
    capture[y][x] = red | (green << 3) | (blue << 6);
}

Where msb_table_inverted is a lookup table to convert our raw GPIO input to the proper R/G/B values. This depends on how we do the analog to digital conversion, so the loop might look slightly different in the end.

Well, how likely is it that this will produce a perfectly synced capture? About 0% in my opinion. If we’re too fast, we’ll get a horizontally compressed image. If we’re too slow, the image will be wider than it should be, and more importantly, cut off on the right side.
In the first case, we may be able to improve the situation by adding the right amount of NOPs.
In the second case, we could reduce the amount of on-the-fly post-processing, and do stuff during HBLANK or VBLANK instead.
In addition, we might miss a few pixels on the left side if we can’t begin capturing immediately when we get our HSYNC interrupt. How likely is this to succeed? It might work, I think.

The PIOs can also be used without DMA. (Instead of using DMA, we’d use functions like pio_sm_get_blocking().) With PIO, we can get perfect timing, which would be really great to have. We can’t off-load any arithmetic or bit twiddling operations, the PIOs don’t have that. So let’s dig in and run some experiments.

Implementation

The pico_examples repository has a couple of PIO examples. The PicoVGA library has a hello world example. I thought the logic_analyser example in pico_examples looked like a good start. It’s really quite amazing.

  • You can specify the number of samples you’d like to read (const uint CAPTURE_N_SAMPLES = 96)
  • You can specify the number of pins you’d like to sample from (const uint CAPTURE_PIN_COUNT = 2)
  • You can specify the frequency you’d like to read at (logic_analyser_init(pio, sm, CAPTURE_PIN_BASE, CAPTURE_PIN_COUNT, 1.f), where “1.f” is a divider of the system clock. I.e., this will capture at system clock speed. We can specify a float number here.)
  • The PIO input is (mostly?) independent from what else you have going on on that pin, so the code of course proceeds to configure a PWM signal on a pin, and to capture from that same pin. Bonkers!

Well, let’s cut to the chase, shall we? I took parts of the logic_analyser code to capture the input from RGB, then wrote some code to massage the captured data a little bit, and then output everything using PicoVGA at a higher resolution. After some troubleshooting, I got a readable signal!

However, my capture has wobbly scanlines. Which is why there might be a part 2. And since it’s wobbly, I spent even less effort on the analog to digital conversion than I’d originally planned, which was already rather “poor man” (more on that later, because the code assumes that circuit exists).

I’m triggering the capture by looking for a positive to negative transition. (That’s already two out of the three instructions my PIO program consists of, one to wait for positive, one to wait for negative.) I currently don’t really know why my scanlines are wobbly. I had a few looks with the oscilloscope to see if there’s anything wrong in my circuit that converts CSYNC to TTL levels — for example, slow response from the transistor. But I didn’t find anything so far. :3 It’s of course entirely possible that the source signal is wonky. I’ve never had a chance to connect my MSX to a monitor that supports 15 KHz signals. (Now that’s a major TODO right there.) Of course there are other ways to check if the signal is okay.

We could also (hopefully) get rid of the wobbling by only paying attention to the VSYNC and timing scanlines ourselves, for example by generating them using the Pico’s PWM. As seen in the original logic_analyser.c code! But that’s something for part 2 I guess.

BTW, it’s unlikely that the wobbliness is being caused by a problem with the code or resource contention. I tested this by switching the capture to an off-screen buffer after a few seconds. The screen displayed the last frame captured into the real framebuffer, and was entirely static. I.e., I added code like this into the main loop (which you will see below):

+        if (j > 600) {
+            rgb_buf = fake_rgb_buf;
+            gpio_put(PICO_DEFAULT_LED_PIN, true);
+        } else {
+            j++;
+        }

Poor-man’s ADC

What I actually planned to do: the program I wrote expects four different levels of red, green, and blue. There are three pins per color, and if all pins of a color are 0, that means that color is 0, if only one is 1, that’s still quite dark, if two are 1, that’s somewhat bright, and if all three are 1, then that’s bright. The program then converts that into two bits (0, 1, 2, 3); PicoVGA works with 8-bit colors, 3 bits for red, 3 bits for green, 2 bits for blue. That means that we can capture all the blue we need, and for red and green we could scale the numbers a bit. However, I shelved that plan for now, because I don’t even have enough potentiometers at the moment, and if the signal is as wobbly as it is, that’s just putting lipstick on a pig. Instead, I just took a single color (blue, just because that was less likely to short my MacGyver wiring), and feed that into all colors’ “bright” pin.

As my MSX’s RGB signal voltages are a bit funky (-0.7 to 0.1 IIRC), I converted that to something the Pico can understand using a simple class A-kinda amplifier. The signal gets inverted by this circuit, but that’s fine for a POC. Completely blue will be black, and vice versa.

So here’s the code:

#include "include.h"

#include <stdio.h>
#include <stdlib.h>

#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/dma.h"
#include "hardware/structs/bus_ctrl.h"

// Some logic to analyse:
#include "hardware/structs/pwm.h"

const uint CAPTURE_PIN_BASE = 9;
const uint CAPTURE_PIN_COUNT = 10; // CSYNC, 3*R, 3*G, 3*B

const float PIXEL_CLOCK = 5369.3175f; // datasheet (TMS9918A_TMS9928A_TMS9929A_Video_Display_Processors_Data_Manual_Nov82.pdf) page 3-8 / section 3.6.1 says 5.3693175 MHz (10.73865/2)
// from same page on datasheet
// HORIZONTAL                   PATTERN OR MULTICOLOR   TEXT
// HORIZONTAL ACTIVE DISPLAY    256                     240
// RIGHT BORDER                 15                      25
// RIGHT BLANKING               8                       8
// HORIZONTAL SYNC              26                      26
// LEFT BLANKING                2                       2
// COLOR BURST                  14                      14
// LEFT BLANKING                8                       8
// LEFT BORDER                  13                      19
// TOTAL                        342                     342

const uint INPUT_VIDEO_WIDTH = 308; // left blanking + color burst + left blanking + left border + active + right border

// VERTICAL                     LINE
// VERTICAL ACTIVE DISPLAY      192
// BOTTOM BORDER                24
// BOTTOM BLANKING              3
// VERTICAL SYNC                3
// TOP BLANKING                 13
// TOP BORDER                   27
// TOTAL                        262

const uint INPUT_VIDEO_HEIGHT = 240; // top blanking + top border + active + 1/3 of bottom border
const uint INPUT_VIDEO_HEIGHT_OFFSET_Y = 40; // ignore top 40 (top blanking + top border) scanlines
// we're capturing everything there is to see on the horizontal axis, but throwing out most of the border on the vertical axis
// NOTE: other machines probably have different blanking/border periods

const uint CAPTURE_N_SAMPLES = INPUT_VIDEO_WIDTH;

const uint OUTPUT_VIDEO_WIDTH = 320;
const uint OUTPUT_VIDEO_HEIGHT = 200;

static_assert(OUTPUT_VIDEO_WIDTH >= INPUT_VIDEO_WIDTH);
static_assert(OUTPUT_VIDEO_HEIGHT >= INPUT_VIDEO_HEIGHT-INPUT_VIDEO_HEIGHT_OFFSET_Y);

uint offset; // Lazy global variable; this holds the offset of our PIO program

// Framebuffer
ALIGNED u8 rgb_buf[OUTPUT_VIDEO_WIDTH*OUTPUT_VIDEO_HEIGHT];

static inline uint bits_packed_per_word(uint pin_count) {
    // If the number of pins to be sampled divides the shift register size, we
    // can use the full SR and FIFO width, and push when the input shift count
    // exactly reaches 32. If not, we have to push earlier, so we use the FIFO
    // a little less efficiently.
    const uint SHIFT_REG_WIDTH = 32;
    return SHIFT_REG_WIDTH - (SHIFT_REG_WIDTH % pin_count);
}

void logic_analyser_init(PIO pio, uint sm, uint pin_base, uint pin_count, float div) {
    // Load a program to capture n pins. This is just a single `in pins, n`
    // instruction with a wrap.
    uint16_t capture_prog_instr[3];
    capture_prog_instr[0] = pio_encode_wait_gpio(false, pin_base);
    capture_prog_instr[1] = pio_encode_wait_gpio(true, pin_base);
    capture_prog_instr[2] = pio_encode_in(pio_pins, pin_count);
    struct pio_program capture_prog = {
            .instructions = capture_prog_instr,
            .length = 3,
            .origin = -1
    };
    offset = pio_add_program(pio, &capture_prog);

    // Configure state machine to loop over this `in` instruction forever,
    // with autopush enabled.
    pio_sm_config c = pio_get_default_sm_config();
    sm_config_set_in_pins(&c, pin_base);
    sm_config_set_wrap(&c, offset+2, offset+2); // do not repeat pio_encode_wait_gpio instructions
    sm_config_set_clkdiv(&c, div);
    // Note that we may push at a < 32 bit threshold if pin_count does not
    // divide 32. We are using shift-to-right, so the sample data ends up
    // left-justified in the FIFO in this case, with some zeroes at the LSBs.
    sm_config_set_in_shift(&c, true, true, bits_packed_per_word(pin_count)); // push when we have reached 32 - (32 % pin_count) bits (27 if pin_count==9, 30 if pin_count==10)
    sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX); // TX not used, so we can use everything for RX
    pio_sm_init(pio, sm, offset, &c);
}

void logic_analyser_arm(PIO pio, uint sm, uint dma_chan, uint32_t *capture_buf, size_t capture_size_words,
                        uint trigger_pin, bool trigger_level) {
    pio_sm_set_enabled(pio, sm, false);
    // Need to clear _input shift counter_, as well as FIFO, because there may be
    // partial ISR contents left over from a previous run. sm_restart does this.
    pio_sm_clear_fifos(pio, sm);
    pio_sm_restart(pio, sm);

    dma_channel_config c = dma_channel_get_default_config(dma_chan);
    channel_config_set_read_increment(&c, false);
    channel_config_set_write_increment(&c, true);
    channel_config_set_dreq(&c, pio_get_dreq(pio, sm, false)); // pio_get_dreq returns something the DMA controller can use to know when to transfer something

    dma_channel_configure(dma_chan, &c,
        capture_buf,        // Destination pointer
        &pio->rxf[sm],      // Source pointer
        capture_size_words, // Number of transfers
        true                // Start immediately
    );

    pio_sm_exec(pio, sm, pio_encode_jmp(offset)); // just restarting doesn't jump back to the initial_pc AFAICT
    pio_sm_set_enabled(pio, sm, true);
}

void blink(uint32_t ms=500)
{
    gpio_put(PICO_DEFAULT_LED_PIN, true);
    sleep_ms(ms);
    gpio_put(PICO_DEFAULT_LED_PIN, false);
    sleep_ms(ms);
}

// uint8_t msb_table_inverted[8] = { 3, 3, 3, 3, 2, 2, 1, 0 };
uint8_t msb_table_inverted[8] = { 0, 1, 2, 2, 3, 3, 3, 3 };

void post_process(uint8_t *rgb_bufy, uint32_t *capture_buf, uint buf_size_words)
{
    uint16_t i, j, k;
    uint32_t temp;
    for (i = 8, j = 0; i < buf_size_words; i++, j += 3) { // start copying at pixel 24 (8*3) (i.e., ignore left blank and color burst, exactly 24 pixels).
        temp = capture_buf[i] >> (2+1); // 2: we're only shifting in 30 bits out of 32, 1: ignore csync
        rgb_bufy[j] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
        temp >>= 10; // go to next sample, ignoring csync
        rgb_bufy[j+1] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j+1] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j+1] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
        temp >>= 10; // go to next sample, ignoring csync
        rgb_bufy[j+2] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j+2] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j+2] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
    }
}

int main()
{
    uint16_t i, y;

    gpio_init(PICO_DEFAULT_LED_PIN);
    gpio_init(CAPTURE_PIN_BASE);
    gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);
    gpio_set_dir(CAPTURE_PIN_BASE, GPIO_IN);

    blink();

    // initialize videomode
    Video(DEV_VGA, RES_CGA, FORM_8BIT, rgb_buf);

    blink();

    // We're going to capture into a u32 buffer, for best DMA efficiency. Need
    // to be careful of rounding in case the number of pins being sampled
    // isn't a power of 2.
    uint total_sample_bits = CAPTURE_N_SAMPLES * CAPTURE_PIN_COUNT;
    total_sample_bits += bits_packed_per_word(CAPTURE_PIN_COUNT) - 1;
    uint buf_size_words = total_sample_bits / bits_packed_per_word(CAPTURE_PIN_COUNT);
    uint32_t *capture_buf0 = (uint32_t*)malloc(buf_size_words * sizeof(uint32_t));
    hard_assert(capture_buf0);
    uint32_t *capture_buf1 = (uint32_t*)malloc(buf_size_words * sizeof(uint32_t));
    hard_assert(capture_buf1);

    blink();

    // Grant high bus priority to the DMA, so it can shove the processors out
    // of the way. This should only be needed if you are pushing things up to
    // >16bits/clk here, i.e. if you need to saturate the bus completely.
    // (Didn't try this)
//     bus_ctrl_hw->priority = BUSCTRL_BUS_PRIORITY_DMA_W_BITS | BUSCTRL_BUS_PRIORITY_DMA_R_BITS;

    PIO pio = pio1;
    uint sm = 0;
    uint dma_chan = 8; // 0-7 may be used by VGA library (depending on resolution)

    logic_analyser_init(pio, sm, CAPTURE_PIN_BASE, CAPTURE_PIN_COUNT, (float)Vmode.freq/PIXEL_CLOCK);

    blink();

    // 1) DMA in 1st scan line, wait for completion
    // 2) DMA in 2nd scan line, post-process previous scan line, wait for completion
    // 3) DMA in 3rd scan line, post-process previous scan line, wait for completion
    // ...
    // n) Post-process last scanline

    // I'm reasonably sure we have enough processing power to post-process scanlines in real time, we should have about 80 us.
    // At 126 MHz each clock cycle is about 8 ns, so we have 10000 instructions to process about 320 bytes, or 31.25 instructions per byte.
    while (true) {
        // "Software-render" vsync detection... I.e., wait for low on csync, usleep for hsync_pulse_time+something, check if we're still low
        // If we are, that's a vsync pulse!
        // This works well enough AFAICT
        while (true) {
            while(gpio_get(CAPTURE_PIN_BASE)); // wait for negative pulse on csync
            sleep_us(10); // hsync negative pulse is about 4.92 us according to oscilloscope, so let's wait a little longer than 4.92 us
            if (!gpio_get(CAPTURE_PIN_BASE)) // we're still low! this must be a vsync pulse
                break;
        }
        for (y = 0; y <= INPUT_VIDEO_HEIGHT_OFFSET_Y; y ++) { // capture and throw away first 40 scanlines, capture without throwing away 41st scanline
            logic_analyser_arm(pio, sm, dma_chan, capture_buf0, buf_size_words, CAPTURE_PIN_BASE, true);
            dma_channel_wait_for_finish_blocking(dma_chan);
        }
        for (y = 1; y < (INPUT_VIDEO_HEIGHT-INPUT_VIDEO_HEIGHT_OFFSET_Y)-1; y += 2) {
            logic_analyser_arm(pio, sm, dma_chan, capture_buf1, buf_size_words, CAPTURE_PIN_BASE, true);
            post_process(rgb_buf + (y-1)*OUTPUT_VIDEO_WIDTH, capture_buf0, buf_size_words);
            dma_channel_wait_for_finish_blocking(dma_chan);

            logic_analyser_arm(pio, sm, dma_chan, capture_buf0, buf_size_words, CAPTURE_PIN_BASE, true);
            post_process(rgb_buf + y*OUTPUT_VIDEO_WIDTH, capture_buf1, buf_size_words);
            dma_channel_wait_for_finish_blocking(dma_chan);
        }
        post_process(rgb_buf + (y-2)*OUTPUT_VIDEO_WIDTH, capture_buf0, buf_size_words);
    }
}

Replace vga_hello/src/main.cpp with the above file and recompile (make program.uf2). Maybe this post will help if you are on something that isn’t Windows and can’t get this to compile.

Explanation

The PIO program is generated in the logic_analyser_init function. Here it is again:

    capture_prog_instr[0] = pio_encode_wait_gpio(false, pin_base);
    capture_prog_instr[1] = pio_encode_wait_gpio(true, pin_base);
    capture_prog_instr[2] = pio_encode_in(pio_pins, pin_count);
    struct pio_program capture_prog = {
            .instructions = capture_prog_instr,
            .length = 3,
            .origin = -1
    };

First we wait for a “false” (low) signal. Then a “true” (high) signal. Then we read. Okay… but that doesn’t make any sense, does it?
No, it doesn’t, but maybe with the following bit of code:

    sm_config_set_wrap(&c, offset+2, offset+2); // do not repeat pio_encode_wait_gpio instructions

sm_config_set_wrap is used to tell the PIOs how to loop the PIO program. And in this case, we loop after we have executed the instruction at offset+2, and we jump to offset+2. The instruction at offset+2 is the “in” instruction. That is, we just keep executing the “in” instruction, except the first time. The first time, we wait for low on CSYNC, then wait for high on CSYNC, and then (as this state means that the CSYNC pulse is over) we keep reading as fast as we can (at the programmed PIO speed).

Results

Let’s take a look at the results. Remember, we’re converting to monochrome, and only looking at the blue channel. Remember that our super lazy “analog frontend” is super lazy, and the potentiometer has to be fine-tuned to get to a sweet spot that allows everything on the screen to be displayed.

The composite signal. Black looking very… let’s call it RGB, is one of the things that motivated me to check if I can get monitor output to work. The other thing is the jailbars. The jailbars are more prominent when showing a dark color.
This is before tuning the capture parameters to ignore HBLANK and VBLANK, so we’re slightly cut off at the bottom and on the right. We’re only feeding into the pin for green here. Everything where blue is at zero intensity is green (top VBLANK and left HBLANK and black characters), and everything where blue is at full intensity, is black. I was running off a slightly wrong pixel clock here. You can see that the boundary between HBLANK green and black is fuzzy. On some scanlines we start a pixel (or fraction of a pixel) early, on others a pixel (or fraction thereof) late. On the next frame, this moves a little. It’s like there’s a somewhat low-frequency wave overlaid over the sync signal. Maybe just our old friend, interference? My CSYNC wire _is_ rather janky. Let’s just say, nothing’s shielded, I’m using a paper clip to get the signal out of the RGB jack, I’m connecting mutiple jumper wires to get to the right length, and the ground wire is crazy long.
And this is what it looks like with the HBLANK and VBLANK front porches ignored, and the pixel clock corrected. (Wait, I still see the horizontal front porch? Must be some qaulity code there.) TBH I have a feeling that the wobbliness increased with the correct pixel clock ;D Um, I’ll get to the bottom of this at some point. (It also looks like we’re ignoring too many scanlines at the top, but that’s okay for now.) Note: the noise you see on the screen isn’t part of the signal, that’s just my camera. This also shows that “m”s don’t look too good. (To my defense, they don’t look too clever on composite either.)
Actually the HBLANK front porches are gone now after I fixed a typo in the code. But it’s still quite wobbly. Maybe not quite as wobbly as in the above video?
Top breadboard converts CSYNC signal to TTL (and there’s some other stuff on there that isn’t used right now). Bottom double breadboard would be large enough for everything, but this sort of grew organically. The “USB POWER” thing is this: https://www.amazon.co.jp/dp/B07XM5FWDW. Super useful tiny power supply that runs off USB! I think I got it cheaper than the current price though. Not shown on this pic, but I run this setup off a small USB power bank, and use the power supply to convert the 5V from USB to 3.3V.
What’s the pen and the eraser doing here? TBH my eyes just tend to filter out junk after a while. So stuff just sort of becomes part of the scenery.

Minor update

Fixing a typo in the code (already fixed above as it made no sense to leave it there) fixed up the signal quite a bit. I also added buttons to fine-tune the pixel clock. This stabilizes the signal significantly. However, hopefully mostly due to the fact that our analog frontend is a bit lame, we get a somewhat fuzzy image, where some pixels change between black and white. I am somewhat tempted to build out the analog frontend properly but before that I think I’ll try my hand at digital RGB, more on that in a later post.

Anyway, here’s the updated code for analog input, with support for two buttons to fine-tune the pixel clock:

#include "include.h"

#include <stdio.h>
#include <stdlib.h>

#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/dma.h"
#include "hardware/structs/bus_ctrl.h"

const uint CAPTURE_PIN_BASE = 9;
const uint CAPTURE_PIN_COUNT = 10; // CSYNC, 3*R, 3*G, 3*B
const uint INCREASE_BUTTON_PIN = 20;
const uint DECREASE_BUTTON_PIN = 21;

const PIO pio = pio1;
const uint sm = 0;
const uint dma_chan = 8; // 0-7 may be used by VGA library (depending on resolution)

const float PIXEL_CLOCK = 5369.3175f; // datasheet (TMS9918A_TMS9928A_TMS9929A_Video_Display_Processors_Data_Manual_Nov82.pdf) page 3-8 / section 3.6.1 says 5.3693175 MHz (10.73865/2)
// the pixel clock has a tolerance of +-0.005 (i.e. +- 5 KHz), let's add a facility to adjust our hard-coded pixel clock:
const float PIXEL_CLOCK_ADJUSTER = 0.1; // KHz

// from same page on datasheet
// HORIZONTAL                   PATTERN OR MULTICOLOR   TEXT
// HORIZONTAL ACTIVE DISPLAY    256                     240
// RIGHT BORDER                 15                      25
// RIGHT BLANKING               8                       8
// HORIZONTAL SYNC              26                      26
// LEFT BLANKING                2                       2
// COLOR BURST                  14                      14
// LEFT BLANKING                8                       8
// LEFT BORDER                  13                      19
// TOTAL                        342                     342

const uint INPUT_VIDEO_WIDTH = 308; // left blanking + color burst + left blanking + left border + active + right border

// VERTICAL                     LINE
// VERTICAL ACTIVE DISPLAY      192
// BOTTOM BORDER                24
// BOTTOM BLANKING              3
// VERTICAL SYNC                3
// TOP BLANKING                 13
// TOP BORDER                   27
// TOTAL                        262

const uint INPUT_VIDEO_HEIGHT = 240; // top blanking + top border + active + 1/3 of bottom border
const uint INPUT_VIDEO_HEIGHT_OFFSET_Y = 40; // ignore top 40 (top blanking + top border) scanlines
// we're capturing everything there is to see on the horizontal axis, but throwing out most of the border on the vertical axis
// NOTE: other machines probably have different blanking/border periods

const uint CAPTURE_N_SAMPLES = INPUT_VIDEO_WIDTH;

const uint OUTPUT_VIDEO_WIDTH = 320;
const uint OUTPUT_VIDEO_HEIGHT = 200;

static_assert(OUTPUT_VIDEO_WIDTH >= INPUT_VIDEO_WIDTH);
static_assert(OUTPUT_VIDEO_HEIGHT >= INPUT_VIDEO_HEIGHT-INPUT_VIDEO_HEIGHT_OFFSET_Y);

uint offset; // Lazy global variable; this holds the offset of our PIO program

// Draw box
ALIGNED u8 rgb_buf[OUTPUT_VIDEO_WIDTH*OUTPUT_VIDEO_HEIGHT];

static inline uint bits_packed_per_word(uint pin_count) {
    // If the number of pins to be sampled divides the shift register size, we
    // can use the full SR and FIFO width, and push when the input shift count
    // exactly reaches 32. If not, we have to push earlier, so we use the FIFO
    // a little less efficiently.
    const uint SHIFT_REG_WIDTH = 32;
    return SHIFT_REG_WIDTH - (SHIFT_REG_WIDTH % pin_count);
}

void logic_analyser_init(PIO pio, uint sm, uint pin_base, uint pin_count, float div) {
    // Load a program to capture n pins. This is just a single `in pins, n`
    // instruction with a wrap.
    static bool already_initialized_once = false;
    uint16_t capture_prog_instr[3];
    capture_prog_instr[0] = pio_encode_wait_gpio(false, pin_base);
    capture_prog_instr[1] = pio_encode_wait_gpio(true, pin_base);
    capture_prog_instr[2] = pio_encode_in(pio_pins, pin_count);
    struct pio_program capture_prog = {
            .instructions = capture_prog_instr,
            .length = 3,
            .origin = -1
    };
    if (already_initialized_once) {
        pio_remove_program(pio, &capture_prog, offset);
    }
    offset = pio_add_program(pio, &capture_prog);
    already_initialized_once = true;

    // Configure state machine to loop over this `in` instruction forever,
    // with autopush enabled.
    pio_sm_config c = pio_get_default_sm_config();
    sm_config_set_in_pins(&c, pin_base);
    sm_config_set_wrap(&c, offset+2, offset+2); // do not repeat pio_encode_wait_gpio instructions
    sm_config_set_clkdiv(&c, div);
    // Note that we may push at a < 32 bit threshold if pin_count does not
    // divide 32. We are using shift-to-right, so the sample data ends up
    // left-justified in the FIFO in this case, with some zeroes at the LSBs.
    sm_config_set_in_shift(&c, true, true, bits_packed_per_word(pin_count)); // push when we have reached 32 - (32 % pin_count) bits (27 if pin_count==9, 30 if pin_count==10)
    sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX); // TX not used, so we can use everything for RX
    pio_sm_init(pio, sm, offset, &c);
}

void logic_analyser_arm(PIO pio, uint sm, uint dma_chan, uint32_t *capture_buf, size_t capture_size_words,
                        uint trigger_pin, bool trigger_level) {
    // TODO: disable interrupts
    pio_sm_set_enabled(pio, sm, false);
    // Need to clear _input shift counter_, as well as FIFO, because there may be
    // partial ISR contents left over from a previous run. sm_restart does this.
    pio_sm_clear_fifos(pio, sm);
    pio_sm_restart(pio, sm);

    dma_channel_config c = dma_channel_get_default_config(dma_chan);
    channel_config_set_read_increment(&c, false);
    channel_config_set_write_increment(&c, true);
    channel_config_set_dreq(&c, pio_get_dreq(pio, sm, false)); // pio_get_dreq returns something the DMA controller can use to know when to transfer something

    dma_channel_configure(dma_chan, &c,
        capture_buf,        // Destination pointer
        &pio->rxf[sm],      // Source pointer
        capture_size_words, // Number of transfers
        true                // Start immediately
    );

    pio_sm_exec(pio, sm, pio_encode_jmp(offset)); // just restarting doesn't jump back to the initial_pc AFAICT
    pio_sm_set_enabled(pio, sm, true);
}

void blink(uint32_t ms=500)
{
    gpio_put(PICO_DEFAULT_LED_PIN, true);
    sleep_ms(ms);
    gpio_put(PICO_DEFAULT_LED_PIN, false);
    sleep_ms(ms);
}

// uint8_t msb_table_inverted[8] = { 3, 3, 3, 3, 2, 2, 1, 0 };
uint8_t msb_table_inverted[8] = { 0, 1, 2, 2, 3, 3, 3, 3 };

void post_process(uint8_t *rgb_bufy, uint32_t *capture_buf, uint buf_size_words)
{
    uint16_t i, j, k;
    uint32_t temp;
    for (i = 8, j = 0; i < buf_size_words; i++, j += 3) { // start copying at pixel 24 (8*3) (i.e., ignore left blank and color burst, exactly 24 pixels).
        temp = capture_buf[i] >> (2+1); // 2: we're only shifting in 30 bits out of 32, 1: ignore csync
        rgb_bufy[j] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
        temp >>= 10; // go to next sample, ignoring csync
        rgb_bufy[j+1] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j+1] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j+1] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
        temp >>= 10; // go to next sample, ignoring csync
        rgb_bufy[j+2] = msb_table_inverted[temp & 0b111]; // red
        rgb_bufy[j+2] |= (msb_table_inverted[(temp & 0b111000) >> 3] << 3); // green
        rgb_bufy[j+2] |= (msb_table_inverted[(temp & 0b111000000) >> 6] << 6); // blue
    }
}

void adjust_pixel_clock(float adjustment) {
    static absolute_time_t last_adjustment = { 0 };
    static float pixel_clock_adjustment = 0.0f;
    absolute_time_t toc = get_absolute_time();
    if (absolute_time_diff_us(last_adjustment, toc) > 250000) {
        pio_sm_set_enabled(pio, sm, false);
        pixel_clock_adjustment += adjustment;
        last_adjustment = toc;
        logic_analyser_init(pio, sm, CAPTURE_PIN_BASE, CAPTURE_PIN_COUNT, ((float)Vmode.freq)/(PIXEL_CLOCK+pixel_clock_adjustment));
    }
}

int main()
{
    uint16_t i, y;

    gpio_init(PICO_DEFAULT_LED_PIN);
    gpio_init(CAPTURE_PIN_BASE);
    gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);
    gpio_set_dir(CAPTURE_PIN_BASE, GPIO_IN);

    blink();

    // initialize videomode
    Video(DEV_VGA, RES_CGA, FORM_8BIT, rgb_buf);

    blink();

    // We're going to capture into a u32 buffer, for best DMA efficiency. Need
    // to be careful of rounding in case the number of pins being sampled
    // isn't a power of 2.
    uint total_sample_bits = CAPTURE_N_SAMPLES * CAPTURE_PIN_COUNT;
    total_sample_bits += bits_packed_per_word(CAPTURE_PIN_COUNT) - 1;
    uint buf_size_words = total_sample_bits / bits_packed_per_word(CAPTURE_PIN_COUNT);
    uint32_t *capture_buf0 = (uint32_t*)malloc(buf_size_words * sizeof(uint32_t));
    hard_assert(capture_buf0);
    uint32_t *capture_buf1 = (uint32_t*)malloc(buf_size_words * sizeof(uint32_t));
    hard_assert(capture_buf1);

    blink();

    // Grant high bus priority to the DMA, so it can shove the processors out
    // of the way. This should only be needed if you are pushing things up to
    // >16bits/clk here, i.e. if you need to saturate the bus completely.
    // (Didn't try this)
//     bus_ctrl_hw->priority = BUSCTRL_BUS_PRIORITY_DMA_W_BITS | BUSCTRL_BUS_PRIORITY_DMA_R_BITS;

    logic_analyser_init(pio, sm, CAPTURE_PIN_BASE, CAPTURE_PIN_COUNT, (float)Vmode.freq/PIXEL_CLOCK);

    blink();

    // 1) DMA in 1st scan line, wait for completion
    // 2) DMA in 2nd scan line, post-process previous scan line, wait for completion
    // 3) DMA in 3rd scan line, post-process previous scan line, wait for completion
    // ...
    // n) Post-process last scanline

    // I'm reasonably sure we have enough processing power to post-process scanlines in real time, we should have about 80 us.
    // At 126 MHz each clock cycle is about 8 ns, so we have 10000 instructions to process about 320 bytes, or 31.25 instructions per byte.
    while (true) {
        // "Software-render" vsync detection... I.e., wait for low on csync, usleep for hsync_pulse_time+something, check if we're still low
        // If we are, that's a vsync pulse!
        // This works well enough AFAICT
        while (true) {
            while(gpio_get(CAPTURE_PIN_BASE)); // wait for negative pulse on csync
            sleep_us(10); // hsync negative pulse is about 4.92 us according to oscilloscope, so let's wait a little longer than 4.92 us
            if (!gpio_get(CAPTURE_PIN_BASE)) // we're still low! this must be a vsync pulse
                break;
        }
        for (y = 0; y <= INPUT_VIDEO_HEIGHT_OFFSET_Y; y ++) { // capture and throw away first 40 scanlines, capture without throwing away 41st scanline
            logic_analyser_arm(pio, sm, dma_chan, capture_buf0, buf_size_words, CAPTURE_PIN_BASE, true);
            dma_channel_wait_for_finish_blocking(dma_chan);
        }
        for (y = 1; y < (INPUT_VIDEO_HEIGHT-INPUT_VIDEO_HEIGHT_OFFSET_Y)-1; y += 2) {
            logic_analyser_arm(pio, sm, dma_chan, capture_buf1, buf_size_words, CAPTURE_PIN_BASE, true);
            post_process(rgb_buf + (y-1)*OUTPUT_VIDEO_WIDTH, capture_buf0, buf_size_words);
            dma_channel_wait_for_finish_blocking(dma_chan);

            logic_analyser_arm(pio, sm, dma_chan, capture_buf0, buf_size_words, CAPTURE_PIN_BASE, true);
            post_process(rgb_buf + y*OUTPUT_VIDEO_WIDTH, capture_buf1, buf_size_words);
            dma_channel_wait_for_finish_blocking(dma_chan);
        }
        post_process(rgb_buf + (y-2)*OUTPUT_VIDEO_WIDTH, capture_buf0, buf_size_words);

        if (gpio_get(INCREASE_BUTTON_PIN)) {
            adjust_pixel_clock(PIXEL_CLOCK_ADJUSTER); // + some Hz
        } else if (gpio_get(DECREASE_BUTTON_PIN)) {
            adjust_pixel_clock(-PIXEL_CLOCK_ADJUSTER); // - some Hz
        }
    }
}
I think my camera doesn’t like this type of scene. It doesn’t look perfect in real life, but not this bad. :p I swear! The noise isn’t there, for example. You can see that the AOC logo is kind of wobbly too, and obviously it isn’t in real life. (I’ll try with a different camera, or without image stabilization next time.)

Compiling PicoVGA on Linux

git clone https://github.com/Panda381/PicoVGA
cd PicoVGA/vga_matrixrain

program.uf2 already exists in this directory, you can copy that to your Pico and it will work.
Let’s try to recompile it though:

.../PicoVGA/vga_matrixrain$ make

Nothing happens but program.uf2 gets deleted. Great.

Let’s try this instead:

.../PicoVGA/vga_matrixrain$ make program.uf2

Output:

ASM ../_boot2/boot2_w25q080_bin.S
Assembler messages:
Fatal error: can't create build/boot2_w25q080_bin.o: No such file or directory
make: *** [../Makefile.inc:469: build/boot2_w25q080_bin.o] Error 1

Let’s create the ‘build’ subdirectory and try again.

.../PicoVGA/vga_matrixrain$ mkdir build

ASM ../_boot2/boot2_w25q080_bin.S
ASM ../_sdk/bit_ops_aeabi.S
ASM ../_sdk/crt0.S
ASM ../_sdk/divider.S
ASM ../_sdk/divider0.S
ASM ../_sdk/double_aeabi.S
ASM ../_sdk/double_v1_rom_shim.S
ASM ../_sdk/float_aeabi.S
ASM ../_sdk/float_v1_rom_shim.S
ASM ../_sdk/irq_handler_chain.S
ASM ../_sdk/mem_ops_aeabi.S
ASM ../_sdk/pico_int64_ops_aeabi.S
ASM ../_picovga/render/vga_atext.S
ASM ../_picovga/render/vga_attrib8.S
ASM ../_picovga/render/vga_color.S
ASM ../_picovga/render/vga_ctext.S
ASM ../_picovga/render/vga_dtext.S
ASM ../_picovga/render/vga_fastsprite.S
ASM ../_picovga/render/vga_ftext.S
ASM ../_picovga/render/vga_graph1.S
ASM ../_picovga/render/vga_graph2.S
ASM ../_picovga/render/vga_graph4.S
ASM ../_picovga/render/vga_graph8.S
ASM ../_picovga/render/vga_graph8mat.S
ASM ../_picovga/render/vga_graph8persp.S
ASM ../_picovga/render/vga_gtext.S
ASM ../_picovga/render/vga_level.S
ASM ../_picovga/render/vga_levelgrad.S
ASM ../_picovga/render/vga_mtext.S
ASM ../_picovga/render/vga_oscil.S
ASM ../_picovga/render/vga_oscline.S
ASM ../_picovga/render/vga_persp.S
ASM ../_picovga/render/vga_persp2.S
ASM ../_picovga/render/vga_plane2.S
ASM ../_picovga/render/vga_progress.S
ASM ../_picovga/render/vga_sprite.S
ASM ../_picovga/render/vga_tile.S
ASM ../_picovga/render/vga_tile2.S
ASM ../_picovga/render/vga_tilepersp.S
ASM ../_picovga/render/vga_tilepersp15.S
ASM ../_picovga/render/vga_tilepersp2.S
ASM ../_picovga/render/vga_tilepersp3.S
ASM ../_picovga/render/vga_tilepersp4.S
ASM ../_picovga/vga_blitkey.S
ASM ../_picovga/vga_render.S
CC ../_sdk/adc.c
CC ../_sdk/binary_info.c
CC ../_sdk/bootrom.c
CC ../_sdk/claim.c
CC ../_sdk/clocks.c
CC ../_sdk/critical_section.c
CC ../_sdk/datetime.c
CC ../_sdk/dma.c
CC ../_sdk/double_init_rom.c
CC ../_sdk/double_math.c
CC ../_sdk/flash.c
CC ../_sdk/float_init_rom.c
CC ../_sdk/float_math.c
CC ../_sdk/gpio.c
CC ../_sdk/i2c.c
CC ../_sdk/interp.c
CC ../_sdk/irq.c
CC ../_sdk/lock_core.c
CC ../_sdk/mem_ops.c
CC ../_sdk/multicore.c
CC ../_sdk/mutex.c
CC ../_sdk/pheap.c
CC ../_sdk/pico_malloc.c
CC ../_sdk/pio.c
CC ../_sdk/platform.c
CC ../_sdk/pll.c
CC ../_sdk/printf.c
CC ../_sdk/queue.c
CC ../_sdk/rp2040_usb_device_enumeration.c
CC ../_sdk/rtc.c
CC ../_sdk/runtime.c
CC ../_sdk/sem.c
CC ../_sdk/spi.c
CC ../_sdk/stdio.c
CC ../_sdk/stdio_semihosting.c
CC ../_sdk/stdio_uart.c
CC ../_sdk/stdio_usb.c
CC ../_sdk/stdio_usb_descriptors.c
CC ../_sdk/stdlib.c
CC ../_sdk/sync.c
CC ../_sdk/time.c
CC ../_sdk/timeout_helper.c
CC ../_sdk/timer.c
CC ../_sdk/uart.c
CC ../_sdk/unique_id.c
CC ../_sdk/vreg.c
CC ../_sdk/watchdog.c
CC ../_sdk/xosc.c
CC ../_tinyusb/bsp/raspberry_pi_pico/board_raspberry_pi_pico.c
CC ../_tinyusb/class/audio/audio_device.c
CC ../_tinyusb/class/bth/bth_device.c
CC ../_tinyusb/class/cdc/cdc_device.c
CC ../_tinyusb/class/cdc/cdc_host.c
CC ../_tinyusb/class/cdc/cdc_rndis_host.c
CC ../_tinyusb/class/dfu/dfu_rt_device.c
CC ../_tinyusb/class/hid/hid_device.c
CC ../_tinyusb/class/hid/hid_host.c
CC ../_tinyusb/class/midi/midi_device.c
CC ../_tinyusb/class/msc/msc_device.c
CC ../_tinyusb/class/msc/msc_host.c
CC ../_tinyusb/class/net/net_device.c
CC ../_tinyusb/class/usbtmc/usbtmc_device.c
CC ../_tinyusb/class/vendor/vendor_device.c
CC ../_tinyusb/class/vendor/vendor_host.c
CC ../_tinyusb/common/tusb_fifo.c
CC ../_tinyusb/device/usbd.c
CC ../_tinyusb/device/usbd_control.c
CC ../_tinyusb/host/ehci/ehci.c
CC ../_tinyusb/host/ohci/ohci.c
CC ../_tinyusb/host/hub.c
CC ../_tinyusb/host/usbh.c
CC ../_tinyusb/host/usbh_control.c
CC ../_tinyusb/portable/raspberrypi/rp2040/dcd_rp2040.c
CC ../_tinyusb/portable/raspberrypi/rp2040/hcd_rp2040.c
CC ../_tinyusb/portable/raspberrypi/rp2040/rp2040_usb.c
CC ../_tinyusb/tusb.c
C++ src/main.cpp
In file included from src/main.cpp:8:0:
src/include.h:13:10: fatal error: ../vga.pio.h: No such file or directory
 #include "../vga.pio.h"  // VGA PIO compilation
          ^~~~~~~~~~~~~~
compilation terminated.
make: *** [../Makefile.inc:458: build/main.o] Error 1

Where do we get vga.pio.h? It’s nowhere in the directory.
Let’s take a look at vga_matrixrain/c.bat:

...
..\_exe\pioasm.exe -o c-sdk ..\_picovga\vga.pio vga.pio.h
...

Hey! pioasm. The repository contains an exe file for this. Is it part of the Pico SDK?
Try:

.../PicoVGA/vga_matrixrain$ locate pioasm

I found it in one of my SDK-related directories. Cool. Let’s try it.

.../picoprobe/build/pioasm/pioasm -o c-sdk ../_picovga/vga.pio vga.pio.h
../_picovga/vga.pio:1.1: invalid character: 
    1 | 
      | ^
../_picovga/vga.pio:13.1: invalid character: 
   13 | 
      | ^
../_picovga/vga.pio:14.13: invalid character: 
   14 | .program vga
      |             ^
../_picovga/vga.pio:17.1: invalid character: 
   17 | 
      | ^
../_picovga/vga.pio:19.1: invalid character: 
   19 | 
      | ^
../_picovga/vga.pio:20.13: invalid character: 
   20 | public sync:
      |             ^
../_picovga/vga.pio:22.11: invalid character: 
   22 | sync_loop:
      |           ^

too many errors; aborting.

One look at hexdump -C ../_picovga/vga.pio | less, we see CRLF line endings. Let’s get rid of them and try again:

tr -d '\015' < ../_picovga/vga.pio > ../_picovga/vga.pio.unix
mv ../_picovga/vga.pio > ../_picovga/vga.pio.windows
mv ../_picovga/vga.pio.unix ../_picovga/vga.pio

.../picoprobe/build/pioasm/pioasm -o c-sdk ../_picovga/vga.pio vga.pio.h # no errors!

Success! Let’s try compiling a little more.

.../PicoVGA/vga_matrixrain$ make program.uf2
C++ src/main.cpp
C++ ../_picovga/vga.cpp
C++ ../_picovga/vga_layer.cpp
C++ ../_picovga/vga_pal.cpp
C++ ../_picovga/vga_screen.cpp
C++ ../_picovga/vga_util.cpp
C++ ../_picovga/vga_vmode.cpp
C++ ../_picovga/util/canvas.cpp
C++ ../_picovga/util/mat2d.cpp
C++ ../_picovga/util/overclock.cpp
C++ ../_picovga/util/print.cpp
C++ ../_picovga/util/rand.cpp
C++ ../_picovga/util/pwmsnd.cpp
C++ ../_picovga/font/font_bold_8x8.cpp
C++ ../_picovga/font/font_bold_8x14.cpp
C++ ../_picovga/font/font_bold_8x16.cpp
C++ ../_picovga/font/font_boldB_8x14.cpp
C++ ../_picovga/font/font_boldB_8x16.cpp
C++ ../_picovga/font/font_game_8x8.cpp
C++ ../_picovga/font/font_ibm_8x8.cpp
C++ ../_picovga/font/font_ibm_8x14.cpp
C++ ../_picovga/font/font_ibm_8x16.cpp
C++ ../_picovga/font/font_ibmtiny_8x8.cpp
C++ ../_picovga/font/font_italic_8x8.cpp
C++ ../_picovga/font/font_thin_8x8.cpp
C++ ../_sdk/new_delete.cpp
ld build/program.elf
uf2 program.uf2
make: execvp: ../_exe/elf2uf2.exe: Permission denied
make: *** [../Makefile.inc:435: program.uf2] Error 127

elf2uf2, I’ve seen that before. Let’s check if that’s in the SDK.

.../PicoVGA/vga_matrixrain$ locate elf2uf2

Found it.

.../picoprobe/build/elf2uf2/elf2uf2

Let’s see what exactly needs to be executed here:

make --trace program.uf2
../Makefile.inc:434: update target 'program.uf2' due to: build/program.elf
echo     uf2             program.uf2
uf2 program.uf2
../_exe/elf2uf2.exe build/program.elf program.uf2
make: execvp: ../_exe/elf2uf2.exe: Permission denied
make: *** [../Makefile.inc:435: program.uf2] Error 127

All right, so we just have to do:

.../PicoVGA/vga_matrixrain$ .../picoprobe/build/elf2uf2/elf2uf2 build/program.elf program.uf2

Let’s put that on the Pico and see what happens.

Freaking amazing TBH :3
Something with a little more color

Success!

Edit Makefile.inc like this to get the build system find elf2uf in the correct location:

diff --git a/Makefile.inc b/Makefile.inc
index 3130ab5..03cf706 100644
--- a/Makefile.inc
+++ b/Makefile.inc
@@ -349,7 +349,7 @@ NM = ${COMP}nm
 SZ = ${COMP}size
 
 # uf2
-UF = ../_exe/elf2uf2.exe
+UF = /path/to/picoprobe/build/elf2uf2/elf2uf2
 
 ##############################################################################
 # File list

材料費220円でMSX用のゲームパッドをゲット?(自作)

ハードオフよ、いつまでもそのままでいてね〜

MSX用のゲームパッドがほしい。

ヤフオクよ、…あれ、めっちゃ高いじゃないか。

早速、ハードオフのジャンクコーナーへ、DE9コネクタの付いているゲームパッドを探しに行った。

あった!

ネオファミって何?ファミコンの互換機じゃないかな?

コントローラは、DE9コネクタ付き。ボタンが必要以上にある。だいぶ前からそこにあったような気がする。(このハードオフ、週1回くらい行っているかな。)ネオファミ本体と2つのコントローラもある。なんかバラ売りみたいだ。

DE9コネクタの穴の中を覗いてみると、金属が入っていない穴も多数あるような気がする。あんま、よろしくない。ケーブル使えない可能性がある。家に半分解体済みのRS232ケーブルがあるので、最悪の場合それを使う。このケーブルも、半年前にハードオフで110円で買っていた!

それでは買ってみよう。110円だし。RS232ケーブルと合わせて、220円。おそらく、どこのハードオフでも、RS232ケーブルはあるんじゃないかな?コントローラーも、正直言って、何でも良い。うまく改造すれば、プレイステーション用のコントローラーなんても使えるのではと思う。110円で何かしら使えるものゲットできると思いますよー

こんなやつです。加工後です…加工前の写真を撮るの忘れた。

ネジを外してみると、やはりケーブルの中は5本のワイヤーしか通っていない。MSX用には使えない。ネオファミとゲームパッド間の通信はおそらくシリアル。確か、スーパーファミコンもそうだった気がします。

基盤に、黒い樹脂の塊が2つある。COB式のICであろう。LEDもある。既存の端子は使わないので、多分、あまり気にしなくても大丈夫だと思う。最悪の場合(つまり、正しくつないでも信号が正常でない場合、つまり、シリアル信号などがMSXへつなぐところに乗ったりする場合)、ドリルなどでチップを壊してしまおう。

MSX用に配線しなおすための下準備

MSXのジョイスティックの配線はとても簡単。

https://www.msx.org/wiki/General_Purpose_port

英語ですが、このページを見れば大体のことがわかる。あれだったら、絵だけでもなんとかなるかと思います… https://www.msx.org/wiki/File:MSX_Joystick_Schematic_Circuit.png これとか。

ただ一つ注意しないといけないのは、一番ピンの位置を間違えないことだ。ジョイスティックから見た場合とMSX本体から見た場合と、ピンの位置が変わるからね。GNDを探すと良いかと思います。MSX本体のRF端子やヘッドフォン端子の外側の金属の部分もGNDなので、ジョイスティックポートのGNDと思われるピンとRF端子などのGNDをテスターの導通モードで特定することができます。

もう一つ注意しないといけないのは、RS232コネクタとMSXジョイスティックコネクタの微妙な違いだ。なんか、RS232コネクタの外側は金属で、GNDだ。MSXジョイスティックの外側はプラスチック。RS232ケーブルはそのままでも挿せるかもしれないし、ちょっと曲げたり削らないと挿せない可能性もある。家で真似する子は今すぐ確認しとおいた方がいいぞ!

私のは、金属の部分はほぼ問題なかったが、まぁ、ちょっとは曲げる必要はあったかな。あと、RS232はコネクタの横にネジがあるのだが、MSX本体にネジ受けがないので、ネジを精一杯回して取り出した。

また、MSXの機種次第だとは思いますがRS232のコネクタが大きいとMSX本体とぶつかる箇所、他にもあるかもしれない。

結局RS232コネクタをまぁまぁ加工する羽目になっちまったぜ。

見栄えはよくないので今度ヤスリで更に加工してみる?

MSX用に配線しなおす

テスターの導通モードでどのワイヤーがどのピンにつながっているか確認する。紙に書いておく。ちなみに5VとGNDはMSXのジョイスティックは使わないのでショートしないようにさえ気をつければOK。

次は、ゲームパッドの基盤を加工する。

ゲームコントローラのほとんどは、導電性のゴムを基盤に押し込むことで2つの金属のパッドをつないで、電気の導通を可能とする原理だ。また、ほとんどの場合(かな?)、金属の2つのパッドうち1つが他のパッドとつながっているかと思いますー。電気的に共通です。GNDにしているコントローラーもあるかもしれないが、ネオファミのはそうではない。純正のMSXコントローラーもそうでないと思います…

MSXでは、ジョイスティックポートの8番ピンをその共通パッドにつなぐ。基盤の都合の良い箇所にはんだ付けする、ということです。他のも都合の良いようにはんだ付けしましょう。緑色のレイヤーを少し削って金属を出す必要があるかもしれない。(左下のはそうだったと思います。)ワイヤーをはんだ付けする前に、パッドに少量の半田を付けると少しは楽になります。

そう言えばガラポンやってるとこ、見たことないなぁ。もう少し早起きして買い物した方がいいですかね?
ワイヤーが、4方向ボタンの上を通っちゃっています…

都合の良い箇所は、よく考えて選んだ方がいいと思います。私ははんだ付けの都合だけ考えて配線しちゃって、分解したゲームパッドの蓋を閉じてネジを締める時のこと、もう少し考えればよかったなぁと思っています!蓋は閉じにくかった。4方向ボタンの上、左、下のパッドはRS232ケーブルが入ってくるところからやや遠くて、ぎりぎり繋げたが、ワイヤーが4方向ボタンの上を通っているので、内部のプラスチックなどに小さな切り込みを入れるしかなかった。のりとかテープも少し使った。ワイヤー、延長すればよかったかな。まぁ今度、ちょっとだけやり直すかもしれないぜ。

切り込み。コーラ安いかな?

その後、やはりワイヤーが切れちゃったのでちょっと配線し直しました。

ネジの穴のと同じ高さになった!
以前は無視していたが、のりは脆かったので簡単に取り除けた

動く?

「Malaika」っていう2006年のゲーム。結構イケてるゲームだと思います!15秒くらいで崖から落ちると思っていたがこういう時に限ってうまく行き過ぎてしまうんだよね。オチのない動画になってしまいました。

アホながら、なんとなく、ゲームをやっていくうち、コツを掴むことができて、見事クリアできました!

正直、結構楽しかった!

黒樹脂の塊はチップですが、取り除かなくても大丈夫?

何のチップかは誰にもわからないので、取り除かないで、とりあえずそのままにして問題にならないか、見てみても良いかと思います。もともとケーブルがつながっていたピンは全部切ったので、チップへの電源供給はないが、チップの入力ピンに電圧があるので、チップが動き出す可能性はなくはない。ただ、出力ピンも切ってあるので、ジョイスティックの仕組みを考えると、入力ピンに信号が出る確率は低いかな、と思います。

ちなみに、「Slow」というボタンを押すとゲームパッド内蔵のLEDが点滅しますが動作にも影響がないです。

コネクタの金属の部分(耳?)が邪魔で Sony のヒットビットにさせなかったので更に加工してみた件

プライヤでコネクタの耳を掴んで何回か上下に曲げると取れることがわかった。Sony のヒットビットにも挿せるようになった!

取れたての時は、中の黒いコネクタも外へ行きたがるが、押し込むとそこそこ安定ですよ。
そして、耳の下の部分はそんな簡単に取れないと思う。野菜切れそう。取扱注意ですな。

Sony HB-10 MSX repair using a Raspberry Pi Pico-powered breadboard logic analyzer

This Sony HB-10 was kept inside its original shrink-wrapped box for around 35 years. (Which is longer than I’ve lived.) It’s incredibly clean. There are two clips on the joystick side that make it hard to open. You can imagine how nervous I was about fumbling about with a practically pristine red box, not knowing where the clips are. Fortunately, I found a YouTube video that showed where they are. They are right underneath where the green tape is in many of the images below. I was using the green tape to block the clips so I could half-close the computer while I wasn’t working on it, without again having to spend ages fighting those silly clips when getting back to the computer.

First of all, some board pics in case anyone needs them:

Cleanest retro computer ever
So reflective :O

None of the chips are socketed. So let’s spy through the oscilloscope and see some worrying things:

Fuzzy IO pin
Suspicious address signals

Anyway, these signals aren’t completely out of spec. (And indeed, at least the fuzzy IO turned out to be normal. I.e., this problem didn’t go away after fixing the computer. I didn’t check for the steppy address lines again after getting the computer to work, but I’d hazard a guess that they’re still there. (Update 2022/09/27: I also fixed an HB-11 a while after that, and it had the same fuzzy IO signal on the pin. Most likely nothing to worry about!)

Anyway, what we’ll do today is… build a 26-channel logic analyzer using a Raspberry Pi Pico! And a large handful of resistors to reduce the 5V signals to 3.3V. We connect the logic analyzer to the ROM chip. The ROM chip’s address and data lines are directly connected to the CPU’s address and data lines, and the RAM data lines. Except there’s no A15, but that’s probably all right for now. Using this, we may be able to figure out what’s going on. (Foreshadowing)

Resistors used for the resistor dividers: 10k, 20k on one side (gets us 3.333V) and 4.7k, 6.8k on the other (gets us 2.957V). Ran out of the higher valued ones. Higher values are better, as you’ll draw less current from the CPU (i.e., be less of a burden). I think you can go pretty high, but I’m sticking with what I’ve used before here.

What is that awesome connector? It’s this: https://akizukidenshi.com/catalog/g/gC-04756/.

And this is the program we’ll run on the Pico:

#include <stdio.h>
#include "pico/stdlib.h"

#define ALL_REGULAR_GPIO_PINS 0b00011100011111111111111111111111
#define LOGIC_BUFFER_LEN 62660

#define TRIGGER_PIN 28

uint32_t logic_buffer[LOGIC_BUFFER_LEN] = { 0 };

int main() {
    int i = 0;
    stdio_init_all();
    gpio_init_mask(ALL_REGULAR_GPIO_PINS);
    gpio_init(PICO_DEFAULT_LED_PIN);
    gpio_set_dir_masked(ALL_REGULAR_GPIO_PINS, GPIO_IN);
    gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);

    // wait until /dev/ttyACM0 device is ready on host
    for (i = 0; i < 10; i++) {
        gpio_put(PICO_DEFAULT_LED_PIN, i%2==0);
        sleep_ms(500);
    }
    gpio_put(PICO_DEFAULT_LED_PIN, 1);
    printf("Logic analyzer ready, waiting for trigger\n");
    while (gpio_get(TRIGGER_PIN) == 0);
    for (i = 0; i < LOGIC_BUFFER_LEN; i++) {
        logic_buffer[i] = gpio_get_all() & ALL_REGULAR_GPIO_PINS;
    }
    printf("Done recording");
    for (i = 0; i < LOGIC_BUFFER_LEN; i++) {
        printf("%04x %04x\n", i, logic_buffer[i]);
    }
    printf("Done printing\n");
}

The TRIGGER_PIN is connected to the RESET line of the Z80. The while(gpio_get(TRIGGER_PIN) == 0) waits for this line to go high. (It’s active-low.) Then we just have a for loop that fills the logic_buffer array with the contents of the GPIO pins that we are using. (I.e., all 26 “normal” GPIO pins.)

Then there’s another for loop, which prints out the contents of the buffer.

Let’s avoid spaghetti wiring, and instead prioritize connection convenience. Which unfortunately means that the GPIO pin numbers and address/data line numbers will be pretty much shuffled now. Which means that we need something to decode the output of the logic analyzer to tell us the contents of the address bus and the data bus. And this is a quick and dirty Perl program to do that. Input is on standard input. The bold lines mean that A14 is on GPIO5, A13 on GPIO4, A12 on GPIO11, etc. D7 is on GPIO22, D6 is on GPIO21, etc.

In the unlikely event that you are reading this, and in the unlikelier event that you are thinking of building this thing, I strongly recommend you connect everything in a way that is convenient for you, and fix the values in these bold lines.

#!/usr/bin/perl

# logic_analyzer_raw2address.pl

@address_positions_from_a14 = (5, 4, 11, 1, 27, 2, 3, 12, 13, 14, 15, 10, 6, 7, 8);
@data_positions_from_d7 = (22, 21, 20, 19, 18, 17, 16, 9);

while (<>) {
    $address = 0;
    $data = 0;
    $num = hex($_);
    $current_address_pin = 14;
    foreach (@address_positions_from_a14) {
        if ($num & (1 << ($_))) {
            $address |= (1 << $current_address_pin);
        }
        $current_address_pin--;
    }
    $current_data_pin = 7;
    foreach (@data_positions_from_d7) {
        if ($num & (1 << ($_))) {
            $data |= (1 << $current_data_pin);
        }
        $current_data_pin--;
    }
    printf ("%04x %02x\n", $address, $data);
}

And the other way round, address&data to logic analyzer value, which will come in handy later. Note that you need to set the input values in the source code, $address_input and $data_input. (They are set to 0x7c86 and 0x21 respectively in the below example.)

#!/usr/bin/perl

# address2logic_analyzer_raw.pl

@address_positions_from_a14 = (5, 4, 11, 1, 27, 2, 3, 12, 13, 14, 15, 10, 6, 7, 8);
@data_positions_from_d7 = (22, 21, 20, 19, 18, 17, 16, 9);

$address_input = 0x7c86;
$data_input = 0x21;

$current_position = 14;
$current_position2 = 0;
foreach (@address_positions_from_a14) {
    if ($address_input & (1<<$current_position)) {
        $mask |= (1 << $address_positions_from_a14[$current_position2]);
    }
    $current_position--;
    $current_position2++;
}
$current_position = 7;
$current_position2 = 0;
foreach (@data_positions_from_d7) {
    if ($data_input & (1<<$current_position)) {
        $mask |= (1 << $data_positions_from_d7[$current_position2]);
    }
    $current_position--;
    $current_position2++;
}
printf("%04x\n", $mask);

So, to run this, you’d do the following:

  • Connect everything up, don’t forget to connect GND between the Pico and the device under test (the HB-10 in this case)
  • minicom -C logic_analyzer_output -D /dev/ttyACM0
  • Wait until you get the “ready” message
  • Turn on the device under test
  • Wait until the Pico is done printing (takes maybe two seconds)
  • Turn off the device under test
  • Exit minicom
  • awk ‘{print $2}’ logic_analyzer_output | perl logic_analyzer_raw2address.pl > logic_analyzer_output_decoded
  • Run openmsx and openmsx-debugger and display logic_analyzer_output_decoded side-by-side
Looking at 7c8c both in the trace and in the emulator

So what do you do if you have reached the end of your trace and would like to see what happens next? In my case I saw that we spent a lot of time in a tight loop initializing memory. That takes up the entire logic buffer. So I’d like to continue reading at a certain address (which can be determined easily by following along in openmsx-debugger), right after the memory is initialized.

That’s where the other Perl script comes in. You think of an address bus value and data bus value where you’d like to continue tracing, and convert that into a value that would be seen by the logic analyzer. Then you modify the logic analyzer program like this, for example:

#include <stdio.h>
#include "pico/stdlib.h"

#define ALL_REGULAR_GPIO_PINS 0b00011100011111111111111111111111
#define LOGIC_BUFFER_LEN 62660

#define ADDRESS_PINS 0b00001000000000001111110111111110
#define DATA_PINS    0b00000000011111110000001000000000
#define ADDRESS_DATA_PINS (ADDRESS_PINS | DATA_PINS)

#define AFTER_MEMCPY 0x27d3cc
#define AFTER_MEMCPY2 0x8101af2

#define TRIGGER_PIN 28

uint32_t logic_buffer[LOGIC_BUFFER_LEN] = { 0 };

int main() {
    int i = 0;
    stdio_init_all();
    gpio_init_mask(ALL_REGULAR_GPIO_PINS);
    gpio_init(PICO_DEFAULT_LED_PIN);
    gpio_set_dir_masked(ALL_REGULAR_GPIO_PINS, GPIO_IN);
    gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);

    // wait until /dev/ttyACM0 device is ready on host
    for (i = 0; i < 10; i++) {
        gpio_put(PICO_DEFAULT_LED_PIN, i%2==0);
        sleep_ms(500);
    }
    gpio_put(PICO_DEFAULT_LED_PIN, 1);
    printf("Logic analyzer ready, waiting for trigger\n");
    while (gpio_get(TRIGGER_PIN) == 0);
    while ((gpio_get_all() & ADDRESS_DATA_PINS) != AFTER_MEMCPY2);
    for (i = 0; i < LOGIC_BUFFER_LEN; i++) {
        logic_buffer[i] = gpio_get_all() & ALL_REGULAR_GPIO_PINS;
    }
    printf("Done recording");
    for (i = 0; i < LOGIC_BUFFER_LEN; i++) {
        printf("%04x %04x\n", i, logic_buffer[i]);
    }
    printf("Done printing\n");
}

And then it’ll start tracing as soon as it sees that the relevant GPIO pins are equal to AFTER_MEMCPY2, which is just a name I came up with.

Logic analyzer output and analysis

Here are the raw traces I produced. You’d need to use the awk command above to convert them.

And here are the three relevant post-processed files:

You can see that we have a very detailed trace of the Z80’s execution. We can easily see what address is being set by the CPU, and what’s being read at or written to that address. You may also notice that we have a couple gaps in the data, which is why we needed a retake for logic_analyzer_output2. You may also be able to tell that things apparently start at 2 here.

  • We can easily see that the Z80 is executing code correctly
  • We can easily see that the ROM is giving us the correct code (the code is identical to what we see in the emulator)
  • We see that the code is trying to switch banks (out #a8) and identify RAM, by overwriting an address and reading back the same address
  • In the emulator, it finds the RAM on first try, because it’s connected on “slot 0”, same as the ROM. (Which is possible because this machine only has 16 KB of RAM and 32 KB of ROM, which is less than the 64 KB addressable by the Z80.)
  • In our logic trace, it gets back a slightly different value from what it had written, which indicates that the RAM is most likely bad!
    • Let’s take a look at 000-03b6_retake.txt around line 6430+, address 0365 to 036e.
    • In Z80 asm, we have here:
      ld hl,#fe00
      ld a,(hl)
      cpl
      ld (hl),a
      cp (hl)
      cpl
      ld (hl),a
      jr nz,#0379
    • This means that we load from #fe00, invert, write this inversion back to #fe00, compare contents of #fe00 with our inverted value, (restore original value,) and if the comparison didn’t quite work out, we jump to #0379.
    • This code is run a number of times, and it shouldn’t jump to #0379 the first time. (It doesn’t in the emulator. It ought to work the first time because ROM and RAM are both in bank 0. But if the RAM is defective, the comparison will fail!)
    • We can also see our loads and stores to memory in the logic analyzer:
      • Line 6510: 7e00 09 (Read 09 from fe00. A15 is missing so fe00 turns into 7e00.)
      • Line 6575: 7e00 f6 (Wrote f6 to fe00. That’s the inversion of 09.)
      • Line 6620: 7e00 f7 (Read f7 from fe00. Last time I checked f6 and f7 weren’t equal.)
  • In our third logic trace, we reach a point where a function is called (7c8c, lines 80-165 in trace) and that function attempts to return. When a function returns, it checks the stack to figure out the correct address to return to (lines 600- in trace). And again, that address doesn’t quite match the address we had written when we executed the CALL instruction! In the trace we can clearly see that it’s reading 7d8f, when it should have been 7c8f. 7d is 01111101, 7c is 01111100. So it would appear that we have a stuck bit in D0.
  • So we now jump to a rather random location, which means we start to execute nonsense code.
  • At some point, the nonsense code jumps to f380 (which is uninitialized RAM). (Note that the trace doesn’t have A15, so it looks like 7380.) And while we’re now completely off the rails and firmly in nonsense territory, the see that everything here appears to have D0 set!

So before we take out the RAM chip, let’s see if we can rule out any other possible malfunctions that could lead to this behavior.

  • The RAM’s address pins are not connected directly to the CPU’s address bus, instead they are most likely connected via the nearby 74LS157 chips (I didn’t check TBH). Could these be the cause of this failure?
    • They would have to magically produce addresses that always have D0 set; that’s very unlikely.
    • When writing the return address to the stack, we should get back the correct value because the same address should be generated when reading and writing. But we’re not reading the correct value back, so it’s very unlikely that the 74LS157 is translating our addresses incorrectly.
  • Some other chip is interfering with the RAM’s output
    • Unlikely, as it’s just a single bit that is erroneous
    • Nothing is interfering with the ROM’s output or IO outputs
    • We could probably see this on the oscilloscope

Checking RAM chips with just a multimeter?

Before taking out the chip (which is quite a chore without a desoldering iron), I put my multimeter in diode mode and checked if there’s anything unusual about the chip. And there was! Putting my positive lead on ground and the negative lead on each of the data pins, I noticed that I got a different voltage drop on the pin for the suspected defective bit, 515 mV. On all others I got 462 mV. (Disclaimer: note that this is an in-circuit test and the RAM chip isn’t the only path from ground to the data pin. I also forgot to check again after removing the chip, so take this with a heap of salt.)

So let’s see what happens when we replace that RAM chip and boot!

Silly metal bar got squashed at first and the silly author of this blog post didn’t notice at first. Now it looks like this. Also guess who didn’t have any replacement chips that day.
Replacement chips arrived. Yay, it works!
Sokoban! I cleared this level. Will take a look at the next level soon. Yeah, maybe tomorrow.

Did you guys know that the word “Sokoban” is Japanese? I only recently realized that when I saw the game for sale somewhere. 倉庫番!

Also, the Raspberry Pi Pico is fast. 3.3V is inconvenient, but not the end of the world.

Old AOC 15″ LM565 LCD repair

I’m not even sure this warrants a blog post. The only thing that was wrong with it was a loose ribbon cable connecting one of the two analog boards to the logic board.

Symptoms: LED and backlights power on, but no logo, no menu. If you connect a VGA cable, the monitor is identified correctly and can be enabled, but no picture.

So… just in case anyone needs board pictures, here they are:

Buttons and sound output
Boards, front side
Boards, back side, and LCD panel

Yay, 1024×768. It was an upgrade from 800×600 back when I got it…