Bare-metal MSX2+ Emulator for ESP32-S3 offers custom LCD_CAM VGA implementation & Z80 optimizations

Ivan Svarkovsky’s S3-MSX-PC open-source project implements a bare-metal MSX2+ emulator running on an ESP32-S3 microcontroller and outputting 64-color VGA via a simple R-2R resistor ladder. It’s a fork of the Retro-Go emulator for ODROID-GO and other ESP32 devices, but with various optimizations.

It was tested on an off-the-shelf ESP32-S3 board with one core handling the game logic and the other video and audio output. VGA is implemented through a clever resistor network that converts digital data into an analog signal that any old monitor understands, while audio relies on Sigma-Delta modulation with a multi-stage PDM filter. The USB host port on the board allows for the connection of a keyboard.

MSX2+ Emulator for ESP32-S3

S3-MSX-PC firmware highlights:

  • Emulation Core – fMSX 6.0 — Full MSX1 / MSX2 / MSX2+ support
  • Video Output
    • VGA 640×480@60Hz, 16-bit parallel RGB via LCD_CAM
    • Color Depth – 64 colors (2 bits per channel: R, G, B)
  • Audio – PDM Stereo with hardware underrun protection
  • USB Host
    • Plug-and-play keyboards
    • Input Latency – ~2–4 ms (USB interrupt-driven, software debounce bypassed)
  • ESP32-S3 optimization
    • Z80 Core Optimization – The standard switch/case opcode dispatcher kills the Xtensa branch predictor, so Ivan implemented Computed Gotos (Threaded Code) instead to pin the PC and ICount variables directly to physical 32-bit registers.
    • MSX palettes are pre-shifted during init to map directly to the GPIO pins of the R-2R ladder. The ESP32’s LCD_CAM peripheral is used to DMA-blast pixels to the monitor, utilizing the hardware lcd_byte_order=1 flag for zero-CPU-cost byte swapping.
    • Fighting PSRAM Latency – Ivan integrated cycle-accurate studio audio chips (PSG, SCC, OPLL), but removed 400KB of PSRAM lookup tables. They were replaced with 1-cycle integer bit-shifts, and the core 11KB tables were locked into DRAM_ATTR.
    • Zero-Heap State Compression – Standard zlib crashes the ESP32-S3 when saving 4.4MB machine states, so Ivan wrote a custom streaming LZ77 compressor (“Delta-Stride LZ”) that understands the MSX VRAM geometry (128/256-byte strides). It performs a vertical rep-match XOR-delta pass and streams directly to the SD card, using exactly 0 bytes of heap RAM.

The project uses the ESP-IDF v5.4.4 framework. The S3-MSX-PC firmware was tested on a DIYables ESP32-S3 development board with an ESP32-S3-WROOM-1-N16R8 module ($4 on AliExpress, $12 on Amazon), but it should work on most other ESP32-S3 boards with similar flash and PSRAM capacity. You’ll also need to add a few resistors and capacitors for VGA video output and the PDM audio filter, plus the USB and VGA connectors wired as shown in the schematic below.

S3-MSX-PC firmware ESP32-S3 VGA audio wiring
Schematics

The result is a bit messy, although it could probably be cleaned up using a breakboard. I initially thought something like the Olimex ESP32-SBC-FabGL board might be a better option since there’s already a VGA connector. However, it’s based on ESP32 with LX6 cores, and Ivan told us that “getting standard emulator code (based on fMSX) to run smoothly on the ESP32-S3 required heavily modifying the architecture to bypass PSRAM latency and Xtensa LX7 pipeline stalls”. So any LX7-specific code might not work.

DIYables ESP32-S3 board VGA connector

You’ll find the source code, binary release, instructions, and a “technical deep dive” with code snippets and compilation metrics on GitHub. Oh, and you can also watch a few short videos there proving the firmware indeed works…

Share this:
FacebookTwitterHacker NewsSlashdotRedditLinkedInPinterestFlipboardMeWeLineEmailShare

Support CNX Software! Donate via cryptocurrencies, become a Patron on Patreon, or purchase goods on Amazon or Aliexpress. We also use affiliate links in articles to earn commissions if you make a purchase after clicking on those links.

Radxa Dragon Q8B Edge AI SBC with Snapdragon 8cx Gen3 SoC

5 Replies to “Bare-metal MSX2+ Emulator for ESP32-S3 offers custom LCD_CAM VGA implementation & Z80 optimizations”

  1. Hi everyone, author here. Thanks to Jean-Luc and CNX-Software for the detailed coverage — the technical depth is spot on.

    The ESP32 (LX6) and ESP32-S3 (LX7) are two different engines. The LX6 is a reliable old carburetor motor. The LX7 is the same block, but with a turbocharger, direct injection, and a 7-speed gearbox instead of 5-speed.

    My code is tuned specifically for that new engine:

    • I use the turbo (SIMD vector instructions) — the old motor simply doesn’t have one
    • I mapped the ignition timing to direct injection (computed gotos bypassing the LX7 branch predictor) — on a carburetor this tuning makes no sense
    • I drive through the 7-speed gearbox (LCD_CAM peripheral with DMA) — the old car only has a 5-speed

    You can transplant the engine, but it’s not “copy the firmware and flash.” You’d need to rebuild the fuel system, remap the ECU, and fabricate a custom transmission mount. In software terms: rewrite the video driver from scratch, replace SIMD with loops, and re-tune every cycle-counting optimization.

    On the audio side: the BOM can be simplified even further. The code supports both stereo and mono output — just flip a switch:
    Stereo mode (current default, dual PDM channels):

    Mono mode (original MSX style, single PDM channel):

    This halves the passive component count — just one RC filter instead of two. True to the original MSX hardware.

    What’s not there: I did attempt integrating the MSX-AUDIO FS-CA1 (1988) — the Y8950 chip (emu8950.c v1.1.4 by Mitsutaka Okazaki). It compiled, it ran, but the result was underwhelming. The problem wasn’t raw CPU cycles — it was the memory architecture.

    Y8950 needs 256 KB for ADPCM sample buffers. Put that in fast DRAM (150 KB free for fMSX) — instant out-of-memory crash. Put it in PSRAM (8 MB available) — the chip chokes on cache misses, dropping from 75 FPS to 28 FPS because it hits slow SPI memory 32,000 times per second for tiny lookup tables.
    Add to that: Y8950 outputs at 49.7 kHz while the system runs at 32 kHz, requiring resampling. The default SINC interpolator (floating-point) swallows the whole CPU. Switch to nearest-neighbor — saves cycles, but sounds rough.
    And the final blow: 1980s software detects Y8950 via precise 80-microsecond timer polling. fMSX processes audio in buffers, not per-cycle, so the timer logic doesn’t exist in real Z80 time. I had to write a virtual timer wrapper in Sound.c that manually calculates Z80 microseconds and raises status bits at the exact right moment — essentially emulating the emulator’s timing.
    The Y8950 is a beautiful chip, but it demands its own memory subsystem and timing domain. Maybe on ESP32-P4.

    Best regards, Ivan Svarkovsky

  2. If you find this interesting, here are some of my notes regarding the audio subsystem optimizations.

    A brief technical report on the issues I faced and how I conquered them:
    
    1. Eliminating RTOS Congestion (The Ultimate Win)

    • The Issue: The native fMSX core generates audio every 8 screen scanlines. Because of this, calling the PlayAllSound() function pinged the RTOS (FreeRTOS) roughly 2,340 times per second with tiny micro-tasks. The ESP32-S3 CPU spent a massive chunk of its execution time on task context switching rather than the actual emulation.
    • The Fix: I built an accumulator. Now, PlayAllSound() simply adds microseconds to a state variable. Only when a complete frame’s worth of audio is buffered (once per frame, i.e., ~60 times per second) does it dispatch a command to the audio task. This freed up a massive amount of CPU cycles, instantly restoring the FPS back to where it belonged.

    2. Implementing Proper Audio Buffering

    • The Issue: Previously, the audio stream was blasted directly to the hardware driver in tiny 10-15 sample fragments. This caused severe overhead and driver instability.
    • The Fix: Inside WriteAudio(), we now aggregate the audio samples into an internal buffer array. The dedicated audio task then submits large, evenly-sized blocks to the hardware driver (rg_audio_submit) in one clean pass.

    3. Underrun Protection (Cracking & Popping Mitigation)

    • The Issue: If the emulator stalled even for a fraction of a second (for example, while loading virtual floppy disk data from the SD card), the audio buffer starved, resulting in a loud, jarring crackle in the speakers.
    • The Fix: I taught audioTask a neat trick. If there is a delay in incoming data from the emulator, the task takes the last few successfully rendered samples and continues to “feed” them to the audio chip. Instead of a harsh cracking sound, it creates a smooth audio hold.

    4. Stabilization & Type Alignment

    • We increased the stack size of the audio task to 4096. This prevents potential Stack Overflow crashes during heavy, complex calculations in the Yamaha (YM2413) chip emulation engine.
    • We aligned the internal data types with the Retro-Go library’s native structures (const rg_audio_frame_t *), allowing the compiler to perform aggressive optimizations without type-mismatch warnings or errors.

    Summary: The emulator stopped wasting precious CPU cycles, the audio flows smoothly, and the FPS remains rock-solid without resorting to aggressive frame-skipping.

  3. Why “Zero-Copy” directly to PSRAM actually won.
    During early development, I had a major misconception: I believed that since external PSRAM is slow, writing every single pixel directly to it during active rendering would be a total bottleneck. Based on this assumption, I fought hard to allocate and keep a 116 KB intermediate video buffer (XBuf) inside the fast, internal DRAM. My plan was to render everything to DRAM first, and then copy the finished frame to the display buffer in PSRAM.

    However, I completely overlooked how the data cache (D-cache) operates inside the ESP32-S3!

    Here is the reality of how the hardware actually manages these memory access patterns:

    • PSRAM struggles with Random Reads (The Z80 bottleneck): When the Z80 interpreter jumps around the codebase, reading single-byte opcodes from disjointed memory locations, it causes frequent cache misses. Every single miss forces the controller to fetch a full 64-byte cache line from PSRAM over the Octal SPI bus, stalling the CPU for up to 100–150 cycles. This is why running execution cores or random-access tables out of PSRAM chokes the system.
    • PSRAM handles Sequential Writes exceptionally well (The VDP breakthrough): When the VDP renders a scanline, it writes pixels sequentially (P[0], P[1], P[2]…). The ESP32-S3’s D-cache intercepts these sequential writes and holds them in a fast internal buffer. Once it has a full 64-byte block, the cache controller flushes it to the PSRAM in a single, highly optimized background burst via the wide Octal SPI bus. The CPU doesn’t have to wait for the write to finish; it just keeps running.

    The Final Zero-Copy Architecture
    Once I understood this, I took a leap of faith: I completely eliminated the intermediate 116 KB DRAM XBuf buffer. Now, the fMSX core renders directly into the PSRAM framebuffer (updates[0] allocated in MEM_SLOW).

    By trusting the D-cache to handle the sequential VDP writes, I achieved two massive wins:

    1. I freed up 116 KB of precious internal DRAM, which is critical on the ESP32-S3.
    2. I completely eliminated a heavy CPU-side memcpy per frame that was previously required to move data from the DRAM buffer to the display PSRAM.

    It turns out that direct-to-PSRAM zero-copy rendering is not only feasible, but it is actually the most efficient way to handle video on this chip.

Leave a Reply

Your email address will not be published. Required fields are marked *

Boardcon MINI1126B-P AI vision system-on-module wit Rockchip RV1126B-P SoC
Boardcon MINI1126B-P AI vision system-on-module wit Rockchip RV1126B-P SoC