To you, from a secret admirer
A Valentine-themed 512 byte “intro” for Lovebyte 2025 where it ranked 4th place in the oldschool 512 byte competition. Well, it’s mainly a procedural image, but it has sound. Choo-choo! :)
Setup
With Lovebyte taking place on Valentine’s day this year, I had the idea to use it as a theme and adapt a well-known bit from the Simpsons.
That Valentine sure was funny. It says “choo-choo-choose me” and there's a picture of a train!
– Ralph Wiggum
I wanted to draw everything with simple shapes and system methods, like I did in Byte Scene Investigation, and maybe with a slightly different approach this time.
- Compact startup code to obtain the current screen’s rastport for drawing and a pointer to graphics.library
- Compress the code with Salvador/ZX0 and with Shrinkler, see what’s smaller
- A main loop that draws a list of graphic primitives
- Print the “choo-choo-choose you” text
- If there is any space left, add sound
Ellipses everywhere
With the startup code in place (similar to the code in B.S.I.)…
move.l 4.w,a6 ; exec base move.l 156(a6),a6 ; IVBLIT.IV_DATA = graphics base move.l 368(a2),a5 ; G_INTUITION = intuition base move.l 56(a5),a5 ; ib_ActiveScreen lea 84(a5),a1 ; sc_RastPort jsr -48(a6) ; ClearScreen(a1:rp)
…I ran some experiments to see what graphics.library functions were a good fit. It was clear that I would need circles for this one!
Function | Details |
---|---|
SetAPen | Required to change drawing color, and not really avoidable: Patching rp_FgPen in the rastport structure directly doesn’t cut it |
AreaEllipse | Gets you a filled ellipse, but the setup is complicated: You also need to call InitTmpRas, InitArea, AreaDraw, and handle the data structures for the area buffer and the “TmpRas” structure. On the plus side, you can combine polygons and ellipses in one go, and you can fake your way around InitTmpRas and InitArea by providing pre-filled mini structures pointing to probably-unused memory |
DrawEllipse | Draws an ellipse, but only the outline. Plus side: Doesn’t need a TmpRas |
Text | Required to print text. Nasty: Overwrites register a1 containing the rastport address; coordinates need to be set with a Move call. Plus side: Doesn’t require a TmpRas, either | LoadRGB4 | Sets a custom palette. Pro: Looks professional and can hide the mouse cursor. Contra: Takes up space for the palette RGB values, needs viewport as an argument |
Seeing that DrawEllipse is a rather “friendly” function (it leaves a1 intact and doesn’t need a TmpRas setup), I re-evaluated the setup:
- What if I could draw everything with ellipses?
- Filled circles can be drawn as many ellipses while the radius getting smaller and smaller
- Or only the width getting smaller. In fact, when I’m already drawing all the ellipses in a loop, I could also add explicit delta-x, delta-y and delta-width values: The ellipses could move, shrink and grow while being drawn
- For more control, it might be a good idea to calculate with a higher precision internally (i. e. extra bits) and shift the x, y, width, height values to the right to get screen pixel coordinates for the DrawEllipse call. This way, the width could shrink by a fraction of a pixel each time, and the circles could move 1/8th pixel to right and 1/4th of a pixel down, etc. Also, we could get more overdrawing this way, avoiding pixel gaps
- With this method, we can also draw kind-of rectangles (a very flat ellipse moving down) and extruding/thinning cylinders
After some prototyping and a lot of printf debugging, I settled on this data structure for the ellipse parameters:
; Internal coordinates have 2 extra bits of precision, ; i.e. screen coordinates * 4 ; ; Kickstart 1.x colors: ; 0=blue, 1=white, 2=black, 3=orange ; ; "count" specifies number of repetitions, ; applying delta x, delta y, delta w each time ; col x y w h dx dy dw count dc.w 2, 202<<2, 100<<2, 87<<2, 12<<2, 2, 4, 0, 64 dc.w 1, 240<<2, 90<<2, 55<<2, 5<<2, 2, 4, 0, 20 dc.w -1 ; end of list
And this is the rendering code; in each step it’s always drawing two ellipses that are 1 horizontal pixel apart (i. e. a “fat” two-pixel wide outline).
lea ellipses(pc),a4 move.w (a4)+,d0 ; first parameter = color .drawloop jsr -342(a6) ; SetAPen(a1:rp,d0:color) movem.w (a4)+,d0-d7 ; get the other loop parameters: ; d0 d1 d2 d3 d4 d5 d6 d7 ; x y w h dx dy dw num .elliloop movem.w d0-d3,-(a7) lsr.w #2,d0 ; convert x,y,w,h to screen coords lsr.w #2,d1 lsr.w #2,d2 lsr.w #2,d3 movem.w d0-d3,-(a7) jsr -180(a6) ; DrawEllipse(a1:rp,d0-d3:x,y,w,h) movem.w (a7)+,d0-d3 addq #1,d0 ; x+1 movem.w d0-d3,-(a7) jsr -180(a6) ; DrawEllipse(a1:rp,d0-d3:x,y,w,h) movem.w (a7)+,d0-d3 movem.w (a7)+,d0-d3 add.w d4,d0 ; x += delta x add.w d5,d1 ; y += delta y add.w d6,d2 ; w += delta w dbf d7,.elliloop move.w (a4)+,d0 ; negative color = end marker bge.b .drawloop
This worked nice enough, and I realized I might not need palette switching at all: Drawing all the ellipses takes quite some time (you would see blank screen until the final palette is set) and seeing the drawing process in real time builds up a little “Where is this going?” tension.
Tool time!

After some doodling in Photoshop, it was clear I needed a tool to set up the circles. Yay!
Writing interactive drawing tools over and over again is kind of relaxing, like a coding kata. With this tool, I would draw the circle outlines with the mouse and manually edit the delta x, y, width values with a live preview update on each change.
At this point, the dreaded old fight against the compression crept in: Simplifying shapes, leaving out details, and adjusting parameters here and there so the ellipse list would contain more repetitions that the packer can eliminate.
Text lines
Adding the text was straight-forward. I wanted to use orange, so I made sure the last ellipse drawn would set the current pen to orange. After some back and fourth, this emerged as the code structure with the best compression:
; After drawing all circles, a4 ; already points to first text chunk. move.l #300<<16+32,d2 ; x,y in 1 longword rept 4 move.l a4,a0 lea 84(a5),a1 ; sc_RastPort move.l d2,36(a1) ; rp_cp_x and _y moveq #8,d0 ; string length 8 add.w d0,a4 ; next 8 characters add.w d0,d2 ; y += 16 add.w d0,d2 jsr -60(a6) ; Text(a1:rp,a0:s,d0:length) endr ... include ellipse-data.asm dc.w -1 dc.b 'I CHOO- ' dc.b ' CHOO-' dc.b 'CHOOSE ' dc.b ' YOU. '
I could avoid setting the text position with an extra Move call by overwriting the “pen position” in the rastport directly.
It needs sound!
A mere picture without any sound would be lame, but staying in the 512-byte limit might get hard… Sound ideas:
- Imitate a “choo-choooo” sound (a short and a long sound, fading out in volume)
- Avoid an explicit waveform and use the start of memory address zero again
- Find a bearable choo-like combination of audio period and length
The first sound code was 20 bytes over budget:
move.l #8<<16+360,$dff0a4 ; length, period move.w #$8001,$dff096 ; enable audio channel 0 move.l #$00010002,d2 .choo swap d2 ; subtract 1.w or 2.w move.w #64,d3 .fade move.w d3,$dff0a8 ; volume jsr -270(a6) ; WaitTOF (1 tick = 1/50 sec) sub.w d2,d3 bge.b .fade bra.b .choo
I experimented a lot with different frequencies and periods…
move.l #72<<16+64,$dff0a4 ;move.l #$000a0500,$dff0a4 move.l #$002a0380,$dff0a4 ;move.l #$000c0400,$dff0a4 ;move.l #$00080380,$dff0a4 ;move.l #$000a02e0,$dff0a4 ;move.l #$000a0200,$dff0a4
…but they all sounded broken, dirty, or painful, and not really like a steam train “chooo”. For the final sound effect, I went for a stereo sound with slightly offset frequencies and periods. Eventually it sounded okay given the constraints!
move.l #$00080220,$dff0a4 ; length, period move.l #$000a0200,$dff0a4+16 ; length, period move.w #$8003,$dff096 ; channels 0 and 1 move.l #$00040001,d2 ; 4-to-1 length ratio .choo swap d2 move.w #96,d3 ; volume > maximum of 64 .fade move.w d3,$dff0a8 ; aud0vol move.w d3,$dff0a8+16 ; aud1vol jsr _-270(a6) ; WaitTOF sub.w d2,d3 bge.b .fade bra.b .choo
All that extra code required a lot more data wrestling to fit into 512 bytes again. I think the biggest saving came from shifting everything vertically pixel by pixel until one specific value yielded a better overall compressed size.
Final touches
Some tasks were still on the todo list:
- That mouse cursor was bugging me!
- Reserve extra space for the decompressed data (avoid overwriting uninitialized memory)
- Try different pupil and nose positions for a goofier, unhinged look – important! :)
- Choose Shrinkler or ZX0
- Test on real hardware
For the mouse pointer, I could shove in a write to disable sprite DMA in the audio loop. This way, the mouse cursor would still be visible during the buildup, but it cost too many bytes doing that right at the beginning.
move.w #$0020,$dff096 ; turn off sprite DMA
Extra space for decompression was reserved in the executable hunk header table – we’re system friendly at no cost and don’t overwrite unallocated memory!
While adjusting the facial features I managed to make the face look funnier and save some bytes.

Compression-wise, Salvador/ZX0 yielded the best overall size again. Who knows, maybe Shrinkler could have won this time?
Packer | Code and data | Compressed size | Total size |
---|---|---|---|
Shrinkler | 688 bytes | 344 bytes | 540 bytes |
Shrinkler with -b | 688 bytes | 355 bytes | 544 bytes |
Salvador | 688 bytes | 384 bytes | 512 bytes |
I was anxious when it was time to test everything on the real machine, but it did work. Phew! Strangely, though, everything hangs when run without fast RAM or with 1 MB of chip – I still need to investigate that for the final version…
Edit: Found it! The unpack code accidentally still contained a hard-coded fixed destination address. Fixed.
