Skip to content

badvision/apple2jpeg

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

apple2jpeg — JPEG decoder for Apple //e DHGR

A JPEG image decoder written in PLASMA for the Apple //e, rendering to the Double Hi-Res Graphics (DHGR) display at 140×192 pixels, 16 NTSC colors. Full baseline JPEG decode — Huffman, IDCT, YCbCr color space conversion — running on a 1 MHz 6502.


Try It Now — In Your Browser

Boot the demo disk directly in the Apple2ts web emulator:

Launch JPEG2DHGR Demo in Apple2ts

The Apple //e will boot, launch PLASMA, and decode TEST.JPG to DHGR automatically. The speed=ludicrous parameter runs the emulator at maximum speed so the decode finishes in seconds rather than minutes. No installation needed.


What It Does

  • Decodes a JPEG file stored on a ProDOS disk to the Apple //e DHGR framebuffer
  • Implements full JPEG baseline decode: Huffman decoding, AAN/Winograd IDCT (Q8 fixed-point), YCbCr-to-NTSC color mapping
  • Supports both 4:2:0 (chroma-subsampled) and 4:4:4 JPEG subsampling
  • Written entirely in PLASMA, a structured systems language that compiles to 6502 bytecode
  • Renders at 140×192 with 16 NTSC colors via the DHGR framebuffer

The output image is characteristic Apple //e DHGR: 16 dithered colors, NTSC color fringing at chroma boundaries, and the particular warmth of a phosphor CRT rendering a decoded photograph from 1983-era hardware.


Demo Screenshot

The demo disk decodes TEST.JPG — a photograph — to DHGR. The screenshot below shows the THBBFT cartoon character decoded on Apple //e DHGR hardware (via Jace emulator), with characteristic NTSC color fringing at color boundaries. This is what the 6502 produced.

THBBFT decoded on Apple //e DHGR via Apple2ts


Building

Requirements

  • PLASMA compiler (xplasm) — builds from source, path configured in Makefile
  • ACME assemblerbrew install acme
  • Cadius disk tool — brew install cadius
  • Jace emulator — for running end-to-end tests (optional)
  • Python 3 with Pillow — for generating TEST.JPG during disk build

Build

make all          # compile all PLASMA sources, build disk image, install modules

This is fully atomic: make all depends on install-modules, which depends on disk, which depends on modules. A clean build produces disk/APPLE2JPEG.po ready to boot.

Individual targets:

make modules          # compile PLASMA sources to .REL binaries only
make install-modules  # install compiled modules onto the disk image
make clean            # remove all build artifacts

Running

The output is disk/APPLE2JPEG.po — a ProDOS bootable 4 MB SmartPort HD image.

In any Apple //e emulator: Mount APPLE2JPEG.po as a hard disk in slot 7, then boot. The disk contains ProDOS, the PLASMA runtime, and an AUTORUN file that launches +JPEG2DHGR automatically. The decoder runs and displays the image on DHGR page 1.

On real hardware: Transfer the .po image to a CompactFlash card or SmartPort-compatible device in slot 7. Boot normally.

In the browser: Use the Apple2ts link above.


Architecture

The decoder is split into five PLASMA modules plus a main entry point:

Module Role
jpeg2dhgr.pla Main entry point. Calls jpeg_decode(), manages DHGR mode soft switches, signals completion.
jpegdec.pla JPEG parser and orchestration. Reads SOF0, DHT, DQT, and SOS segments; drives MCU decode loop; calls IDCT and DHGR output.
jpegio.pla ProDOS MLI file I/O and bitstream reader. Buffers the JPEG byte stream and provides single-bit reads for Huffman decode.
huffdec.pla Canonical Huffman decoder. Builds min/max code tables per bit length; decodes DC and AC coefficients from bitstream.
jpegidct.pla AAN/Winograd IDCT in Q8 fixed-point. Performs 8x8 2D IDCT on dequantized DCT coefficients.

The modules use dhgrlib from the DARTHGR project for DHGR pixel writes. DHGR pixel addressing on the Apple //e requires interleaving writes between MAIN and AUX memory banks via the 80-store soft switch ($C000), which the library manages.

Memory is heap-allocated via the PLASMA runtime. No fixed addresses are used for decoder buffers; quantization tables (512 bytes), MCU buffers (384 bytes), and IDCT scratch buffers are all allocated at module init.


Retrospective: How This Came Together

This project had a harder road than it should have. The algorithm is well-understood — JPEG baseline has been documented exhaustively for 30 years. The 6502 is constrained but not mysterious. And yet getting a correct image out took significantly more iterations than the problem warranted. Here is what actually happened, and why.

The Bugs

1. Sign extension in imul_q8 (precautionary, turned out redundant). The IDCT multiplier imul_q8(a, b) computes the high byte of a for a Q8 shift: a_hi = a >> 8. Concern was that PLASMA's >> might be a logical (unsigned) shift, which would corrupt the sign of negative IDCT coefficients. A sign-extension guard was added. Post-ship inspection of the PLASMA VM source (pwmvm.s) showed the SHR opcode uses CMP #$80 / ROR — which IS arithmetic right shift (sign-preserving). The guard is harmless but unnecessary. The chroma color errors that prompted this investigation were actually caused by Bug #4 (Huffman sentinel) — once the sentinel was fixed, chroma decoded correctly regardless of the shift behavior.

2. Modules missing from disk. make disk built the ProDOS image but did not install the compiled modules — that was make install-modules, a separate step. The test was "passing" because the Jace terminal output contained the word DONE even when the program never ran (it appeared in boot messages). The fix was making make all depend atomically on install-modules. The lesson: a test that can pass without exercising the code is not a test.

3. PLASMA NOT is bitwise. A debug guard used NOT dbg_lut_done to print a lookup table once. NOT 1 = $FFFE, which is truthy in PLASMA, so the "print once" guard printed on every pixel call — 16,384 times per decode. This flooded the text screen, exceeded test timeouts, and caused only partial decoding. The output looked broken in a way that had nothing to do with the decoder logic.

4. Huffman decoder sentinel bug. huff_build_table stored min_code = 0xFFFF for empty bit-length entries to signal "no codes at this length." PLASMA's >= is signed, so 0xFFFF = -1. Every Huffman code satisfied code >= -1. False matches returned wrong symbols for all chroma AC and DC components. Fixed by changing the sentinel to min=0, max=0xFFFF — a condition that code >= 0 AND code <= -1 can never satisfy.

5. JPEG 4:2:0 MCU chroma bleed. The test image used 14-pixel-wide color bars, but JPEG 4:2:0 MCUs are 16×16. Every MCU straddled two bars, averaging incompatible chromas and producing muddy, wrong colors at every boundary. Fixed by switching to 4:4:4 subsampling and 16-pixel-wide bars. The lesson is that test images need to be designed for the codec's block structure, not just "some colors."

6. dhgrMode(DHGR_TEXT_MODE) broke AUX writes. Switching to text mode before decoding in an earlier iteration disabled 80-store ($C000), which controls AUX memory routing for DHGR. All subsequent dhgrPixel calls wrote to MAIN instead of AUX, producing a half-decoded image with one memory bank blank. Fixed by using ^$C051 (showtext soft switch only) to display diagnostics without touching 80-store.

7. The test image was 8×8. The disk build target was guarded by an existing file check; TEST.JPG was never regenerated after changing the build script. The SOF marker showed 8x8 MCU:1x1 — 64 pixels decoded. Everything else looked fine. Fixed by deleting the disk target and forcing a full rebuild.

What Made the Difference

The Haiku vision validator. Statistical pixel analysis — percentage of non-black pixels, color variance — was completely useless for distinguishing "garbage noise that happens to have some color" from "recognizable image." These metrics passed on broken output and failed on good output depending on the specific bug. Switching to a Claude Haiku vision model as the acceptance criterion — asking it to describe what was visible — gave an honest signal on every run. If the model said "I see a face," the decoder worked. If it said "I see colored noise," it didn't.

Orchestrated sub-agents. The debugging was going in circles with a single agent reading files, forming hypotheses, making changes, and running tests in sequence — burning context window without convergence. Switching to a pattern where a technical-analyst subagent investigated the specific failure and a tdd-software-engineer subagent implemented the fix, with findings passed through the orchestrator, broke the cycle. Each agent started fresh on a concrete, scoped problem.

Requiring data. The most expensive iterations were the ones where a hypothesis ("this should work because...") was accepted without verification. Requiring actual test output — pixel values, emulator screenshots, hex dumps — before accepting any explanation eliminated entire classes of wasted cycles. The rule was: if you haven't run it and shown the output, you haven't fixed it.

The project is a useful reminder that constraint and obscurity compound. On a 1 MHz 6502 with 64 KB of RAM, writing in a language that compiles to bytecode, targeting a display mode that requires interleaved memory bank writes — there is very little slack. Every assumption about how a language construct behaves, every assumption about the build system, every assumption about what a passing test proves, needs to be verified against what the machine actually does.

About

JPEG decoder for Apple //e DHGR — written in PLASMA, renders to 140×192 16-color display

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors