EVENT HORIZON
A demoscene production for the SAINTCON 2026 badge
By Octavian C · Source on GitHub
- Platform
- RP2350 · 2× Cortex-M33
- Display
- 360×360 round, RGB565
- Length
- 54 s loop, 4 scenes
- Renderer peak RAM
- 455,972 B · 85.6%
259,200 bytes for the framebuffer. On an RP2350 with 520 KiB of SRAM, that is almost half the memory gone before drawing anything.
EVENT HORIZON is a 54-second looping demo for the
SAINTCON 2026 badge: four software-rendered effects,
two Cortex-M33 cores, a 360×360 round display and nine RGB LEDs. The
D-pad steers the camera. The renderer occupies 85.6% of SRAM. Stock
firmware stays one boot away.
Four effects
Chrome knot
Tunnel
Terrain
Particles
- Chrome knot and plasma. A
160×16mesh follows a (2,3) torus knot. A128×128environment map supplies the chrome highlights; a camera dive and white-out carry the cut into the tunnel. - Tunnel. Angle and inverse-distance lookups turn screen positions into
coordinates in a
128×128bevelled texture. We store one quadrant and mirror it: a full table with camera margin would cost four times as much. There are 1,024 angle steps per turn. - Terrain. A heightfield renderer marches columns front to back,
drawing only what nearer ridges have not hidden. Generated
256×256height and colour maps supply the landscape; the camera follows an envelope above the peaks. - Particles.
8,192fragments inherit the final terrain frame's colours, scatter, then rebuild the title. Their positions are functions of time; an 80 ms look back along the same path produces the trails without a history buffer.
Spend the memory where it shows
The first renderer ran at 180×180 and doubled the pixels. Native
360×360 RGB565 added 194,400 bytes to the framebuffer, buying smoother
silhouettes, tunnel seams and lettering across the whole production.
The effects share a workspace union. Tunnel tables give way to terrain maps, then particle data. At the last transition, we recreate the terrain's final frame and seed the particles before overwriting its workspace.
Peak core RAM is 455,972 bytes: 259,200 for pixels, 196,696 for the workspace and 76 for demo state. Calculated size and painted high-water agree. The remaining 76,508 bytes must also accommodate stacks, drivers and the SDK runtime; 85.6% describes the renderer core, not the whole firmware.
The chrome has two resolutions: the mesh and the reflections it samples.
Doubling the environment map from 64×64 to 128×128 costs 24,576 bytes.
The reflected horizon gets a cleaner edge even though the geometry stays
the same.
The knot started at 320 rings. Reducing it to 160 made no visible difference at native scale, with only a faint facet at 4× zoom, and saved about a quarter of the scene's estimated device time. The terrain still determined the RAM peak.
Walls and ridges
The tunnel's bevelled panels are a texture trick: a bright seam beside a
dark line. With a 64×64 texture those seams turn into broad steps;
128×128 buys finer detail for another 24,576 bytes. Mirroring the lookup
tables saves space elsewhere, without throwing away angular precision.
Terrain spends much more on its maps. Raising them from 128×128 to
256×256 adds 147,456 bytes and preserves detail in the nearby ridges.
The renderer marches toward the horizon, keeping the highest visible
boundary in each column. Once a ridge hides the ground behind it, those
pixels need no work. A full-screen depth buffer never enters the picture.
Motion without history
The particles carry the landscape's colours into the lettering. Their target assignment visits every letter pixel before repeating; with only 2,048 fragments, the title has holes. We kept 8,192.
A trail joins the particle's position now to its position 80 ms earlier. Both come from the same function of time: no previous image, no history buffer. Switch trails off and the streaks become scattered dots. That motion is also seekable, which made a particular instant easy to reproduce for visual comparisons and regression checks.
Two cores, one picture
The knot gets depth testing through two 360×45 depth bands, one per
worker. Each core rasterises the triangles overlapping its band, then
reuses that scratch space for the next part. A full-screen depth buffer
never has to fit.
Each frame has eight parts and one or two passes. Geometry or particle segments are prepared first; barriers separate dependent passes. Within a pass, workers write disjoint pixels. The host runs the same plan, with tests requiring bit-identical output from serial and two-worker schedules.
Built with the Pico SDK, the LCD driver uses PIO and DMA to stream the existing 16-bit framebuffer to the ST77916 panel without a second image or an endian-swapped copy. Rendering waits until the previous transfer has finished; the backlight comes up after the first frame reaches the panel.
Leave a way home
The menu lives at flash offset zero, stock at 0x010000 and the demo at
0x200000. The RP2350 bootrom launches the selected partition through
chain_image. Address translation presents stock at its expected
execution address, so its bytes stay unmodified.
The current selector gives three seconds to choose:
- LEFT / RIGHT: Wayfarer or EVENT HORIZON; each press restarts the timer.
- CENTRE: launch the highlighted choice now. Timeout uses that choice; the default is Wayfarer.
- RIGHT held at power-on: bypass the screen and start the demo directly.
Stock gets one extra guard: all seven buttons released for 100 ms before handover. Its saved state lives in a separate 1 KiB EEPROM, outside the flash backup, and stock has a button-reachable erase path. The demo can start with keys down; its input handling cannot reach the EEPROM. A/B do not choose a boot image.
Source and binary audits check the menu and demo for EEPROM-related I2C and GPIO access. Deliberately broken examples exposed gaps in early audits; the corrected checks reject missing definitions and unsupported pin expressions. This supports a bounded safety case, not a formal proof.
The build accepts only the exact Wayfarer v0.1 firmware it was developed against, with the rest of flash erased. Whether other badges match is unknown; a mismatch stops the build.
Credits
Shoutout to Professor Plum for making the badge, and to bashNinja for the inspiration through his web flasher with NES and Game Boy emulators and ROMs.
The effects are implemented in our C renderer. The LCD/QSPI driver derives from redactd's MIT-licensed saintcon26-gif-player; the LED PIO program derives from Raspberry Pi's BSD-licensed WS2812 example. Panel-init values are generated locally from our own backup and remain private.
Claude and Codex agents worked across implementation and independent review, under human creative direction. Kaleidoscopico was an early reference for the RP2350's demo potential.