Tinyjoypad_SDL

A Port of the attiny85, Tinyjoypad games to SDL2/3 and Playdate


Project maintained by joyrider3774 Hosted on GitHub Pages — Theme by mattgraham

TinyJoypad → SDL3 Port

Goal

A native SDL3 port of the sibling tinyjoypad_vircon32 project (33 TinyJoypad games behind one shared menu, originally targeting the Vircon32 fantasy console) - same games, same menu shape, but as a plain desktop executable with no emulator dependency. This file is project context for Claude Code, mirroring the sibling project’s own CLAUDE.md in spirit: read it before making changes, keep it updated as decisions get made.

This project’s own history is short relative to the sibling one on purpose. The actual game logic - every bug found and fixed, every per-game optimization - lives in tinyjoypad_vircon32’s own CLAUDE.md/ OPTIMIZATIONS.md, and this port reused those files essentially unchanged (only a mechanical dialect conversion touched them - see below). Don’t duplicate that history here; link to it. What belongs in this file is what’s actually specific to the SDL3 platform layer: the architecture, the dialect-conversion process itself, and the bugs/design decisions that only exist because this is a different target than Vircon32.

Relationship to sibling projects

Architecture

Translation-unit boundary

Two separate compiled halves, communicating only through machineDependent.h’s plain declarations - mirrors exactly how tinyjoypad_vircon32 separates machineDependent.h from portVircon32.c:

Directory layout / multi-port structure

SDL3/              <- vendored SDL3 checkout (repo root, not inside src/sdl3/)
SDL2/              <- vendored SDL2 checkout (repo root, not inside src/sdl2/)
assets/            <- thumbnails etc - port-agnostic content (SDL ports only);
                      thumbnails/thumbnailData.h here is generated, embedded
                      into both SDL ports' own exe at compile time (see
                      "Thumbnails" below), not shipped/read at runtime
tools/             <- gen_thumbnails.py - regenerates that header from the
                      checked-in thumb_NN.bmp source files
src/
  gameworld/       <- the "game world" side above - every port reuses this
                      almost entirely UNCHANGED (see "The Playdate port"
                      below for the two genuine exceptions); it never
                      #includes SDL.h/pd_api.h and knows nothing about any
                      specific port
    games/
  sdl3/            <- the SDL3 port: its own *.c/*.h, its own standalone
                      CMakeLists.txt, own build/ output dir
  sdl2/            <- the SDL2 port: same shape as sdl3/, own *.c/*.h/
                      CMakeLists.txt/build/
  playdate/        <- the Playdate port: one main.c (SDK convention - see
                      its own header comment), its own CMakeLists.txt (SDK-
                      template-shaped, not this repo's other two ports' own
                      shape - see that file's own comment), Source/pdxinfo

No top-level CMakeLists.txt. Each port under src/ is a fully independent CMake project (its own project(), its own add_executable()/ add_library()) built by cd-ing into it directly (cd src/sdl3 && cmake -B build ..., cd src/sdl2 && ..., cd src/playdate && ...) - not a subdirectory pulled in by a repo-wide orchestrator. This is a direct, deliberate mirror of the same-author sibling crisp-game-lib-portable-sdl project’s own layout (src/games/ + src/lib/ shared, then one directory per port - src/cglpSDL3/, src/cglpSDL2/, src/cglpPlaydate/, src/cglpPyBadge/, … - each with its own standalone CMakeLists.txt), adopted here on direct user request specifically so a second (and third) port could reuse src/gameworld/ (all 33 games included) with minimal or no changes, by just adding a new src/<portname>/ directory alongside src/sdl3/, with its own CMakeLists.txt globbing ../gameworld the same way src/sdl3/CMakeLists.txt already does - no shared root build file to edit or fight over, and no existing port’s own build is affected by adding a new one. src/sdl2/ is the cleanest proof: built entirely by copying src/sdl3/’s own files and porting the SDL3->SDL2 API differences, with zero changes anywhere under src/gameworld/. src/playdate/ (below) needed exactly two, both genuine latent bugs unrelated to Playdate itself

An earlier revision of this project had one repo-root CMakeLists.txt and src/platform/ instead of src/sdl3/ (single-port, no multi-port provision) - src/platform/ was renamed to src/sdl3/ and the CMakeLists.txt moved down into it essentially unchanged (only the relative paths reaching ../../SDL3, ../gameworld, and ../../assets are new) as part of this restructuring. README.md’s own Building section was updated to match - the build command now starts with cd src/sdl3 (or cd src/sdl2).

The SDL2 port (src/sdl2/)

Built by copying every file from src/sdl3/ and mechanically porting each SDL3 API call to its SDL2 equivalent, cross-checked against the sibling crisp-game-lib-portable-sdl project’s own src/cglpSDL2/ (both its CInput.c - button/axis/event-constant names - and cglpSDL2.c - window/ renderer/audio setup) wherever a design choice, not just a rename, was needed. Verified functionally identical to the SDL3 port afterward, not just “it compiles”: every numeric constant (audio amplitude/sample rate, glow/CRT intensities, joystick deadzones, the 0.05 volume step) diffed equal, every button/keyboard/axis/mouse-event field mapping table diffed identical once enum names are stripped out, main.c and sdlBackend.h diff to only comment/include-line changes (zero logic differences), and both ports independently pass the full 33-game -ms batch-screenshot regression (every game’s real init()/update(), not just a smoke test) with no crashes. The one deliberate non-literal choice - SDL_WINDOW_FULLSCREEN_DESKTOP or 0 for the SDL2 port’s fullscreen flag, vs. the SDL3 port’s plain SDL_WINDOW_FULLSCREEN/SDL_SetWindowFullscreen(win, bool) - still produces the same real behavior on both: SDL3 changed SDL_WINDOW_FULLSCREEN’s own default meaning to “borderless, current desktop resolution” (an actual exclusive display-mode change needs an explicit SDL_SetWindowFullscreenMode() call this project never makes), which is exactly what SDL2’s FULLSCREEN_DESKTOP flag has always meant - so despite the different flag name, neither port ever does a real display-mode switch.

Real API differences found doing this port (useful reference for any future port too, not just this pair):

The Playdate port (src/playdate/)

A genuinely different kind of port from src/sdl2/’s own mechanical API translation: Playdate is fixed 400x240 1-bit hardware with its own C SDK (pd_api.h - windowless, event-callback-driven, no CLI), not another desktop windowing library, so this port is built directly against the official Playdate SDK, cross-checked against the same-author sibling crisp-game-lib-portable-sdl project’s own src/cglpPlaydate/src/cglpPlaydate.c (per direct user request) for the parts that are genuine design questions (how pd->display->setScale()/setOffset() map a small logical canvas onto the real panel, the synth-based audio approach, the SDK’s own eventHandler()/update-callback shape) rather than a line-by-line translation the way src/sdl2/ was.

Deliberately out of scope, per direct user request (“we don’t need the effects, we don’t need color to pattern stuff… we don’t need all the command line and SDL specific stuff”):

A brand-new, Playdate-native menu, not gameworld/menu.c’s own - the single largest design departure, and flagged as likely necessary by the user themselves before any code was written. gameworld/menu.c’s own menu_update() assumes a real 640x360 canvas and draws through the BIOS font (biosFont.h, 10x20px glyphs) with thumbnail images sized for that canvas - none of that fits a 400x240 1-bit panel, and downscaling a virtual 640x360 canvas to 400x240 on every frame would be exactly the kind of manual scale/offset math this project’s own SDL ports deliberately avoid (see “Rendering model” above). src/playdate/main.c’s own menuUpdate() is a from-scratch replacement instead, but ended up matching gameworld/menu.c’s own feature set almost exactly (per direct user follow-up requests after the first working version) even though the rendering underneath is entirely different: Playdate’s own system font (/System/Fonts/Roobert-10-Bold.pft, loaded by path - every Playdate already ships every /System/Fonts/* font, nothing to bundle) drawn via pd->graphics->drawText(), a real title line, a keybind hint line, a zero-padded-numbered (“01.”, “02.”, …) alphabetized list (a local gDisplayOrder[] - the exact same selection-sort-by-title algorithm as gameworld/menu.c’s own private displayOrder[], just re-implemented here rather than exposing that array through a new cross-port menu.h accessor), genuine LEFT/RIGHT-jumps-a-page pagination (a PAGE 1/4-style indicator, not the first version’s own continuous-scroll list - fewer games fit per page here than gameworld/menu.c’s own GAMES_PER_PAGE==9, since this port’s own header/hint/page-indicator lines and the thumbnail column below eat more vertical/horizontal room), and a real gameplay thumbnail + “BY " caption for the currently-selected game. It still walks the exact same `menu.h` `Game` table (`menu_getGame()`/ `gameCount`, populated by the completely unmodified `menuGameList.c`'s own `addGames()`) - only the rendering/navigation code around that table is new, not the registration data itself. Also skips `gamesMain.c`'s own quit-*confirmation* dialog entirely (no BIOS font to draw it with either, and per direct user confirmation that no confirmation step is wanted here) in favor of matching `cglpPlaydate.c`'s own simpler precedent exactly: holding A+B+Up+Right together returns to the menu immediately, no YES/NO prompt - an unlikely-to-happen-by-accident chord none of these 33 games' own real controls use simultaneously.

Thumbnails: src/playdate/Source/thumbnails/thumb_00.png .. thumb_32.png, generated from the exact same assets/thumbnails/*.bmp every SDL port already uses (magick thumb_NN.bmp -sample 128x64 thumb_NN.png - point-sampled, not a blurring resize filter, to keep the already near-monochrome source crisp before Playdate’s own pdc build tool dithers it to 1-bit) - downscaled exactly 2x from their original 256x128, which lands precisely on TinyJoypad’s own real OLED resolution, not a coincidence-free choice. Loaded once at startup via pd->graphics->loadBitmap() (a Playdate asset is referenced by path minus extension - pdc itself converts a bundled Source/*.png into its own runtime bitmap format at build time), probed sequentially the same way md_getThumbnailCount() does on every other port, and drawn/captioned via the exact same registration-index indirection gameworld/menu.c’s own thumbnail code uses (gDisplayOrder[selection], not selection directly - the thumbnail set is keyed by registration order, matching how the files were generated, not by alphabetized display position).

Video: md_drawColumn() scales manually - GAME_SCALE (3) times the native OLED resolution (128x64) lands almost exactly on the real panel (384x192, centered with an 8px/24px border via GAME_ORIGIN_X/_Y) - matching every SDL port’s own identical GAME_SCALE multiply, just emitting pd->graphics->fillRect() calls instead of SDL_FillSurfaceRect() ones. This is a deliberate reversal of this port’s own first working design, which tried using pd->display->setScale()/setOffset() instead (see “Bugs found” below for why that had to be abandoned) - md_setInGame() is consequently a near-no-op now (nothing left to toggle, since there’s no longer any mode-dependent display transform at all), and md_beginFrame()/ md_endFrame() don’t touch pd->display in any way, only pd->graphics->clear(). kColorWhite for an “on” bit against a kColorBlack background - direct user choice, overriding an earlier revision of this port’s own reasoning here (which had the two swapped: kColorBlack “on”/kColorWhite background, on the theory that Playdate’s reflective panel - dark ink on a light background, like paper - is the opposite polarity from a real SSD1306 OLED, where a “lit” pixel is the bright foreground, matching cglpPlaydate.c’s own default non-“dark- color” convention). The current choice matches the original OLED’s own polarity directly instead (bright “on” pixels on a dark background), not inverted for the panel.

Pixel-grid effect + system menu - the one SDL-port presentation effect this port does get an equivalent of, added on direct user request after the rest of the port was otherwise complete (“we don’t need the effects” in the original scope note above was about glow/CRT specifically, not this). Same “pre-baked once, composited every frame” design as pixelGridEffect.c’s own SDL version (see “Presentation effects” above) - pixelGridEffectInit() (called once from init()) draws a GAME_SCALE- spaced 1px black grid into one GAME_SCALE*OLED_WIDTH x GAME_SCALE*OLED_HEIGHT LCDBitmap, and pixelGridEffectRender() (called from update()’s gameplay branch, after that game’s own update(), gated to gameplay only exactly like every SDL port’s own three effects) just drawBitmap()s it at GAME_ORIGIN_X/_Y every frame - no runtime per-pixel drawing, matching the CRT/pixel-grid SDL modules’ own “bake once, scroll/composite thereafter” lesson from earlier in this project. Unlike the SDL ports (one shared G/West-button cycle across all three effects), this port has no equivalent spare physical button, so it’s exposed through Playdate’s own system menu instead (pd->system->addCheckmarkMenuItem(), opened by the player’s own physical Menu button during gameplay) - default OFF, state persists across games/menu visits within the same session (the checkbox is seeded from gPixelGridEnabled itself, not a hardcoded 0, each time a game launches and re-adds the menu items). The same system menu also gained a "Menu" entry (pd->system->addMenuItem()) as a second way to reach returnToMenu(), alongside the existing A+B+Up+Right chord - both call the same function, so there’s exactly one place that resets gCurrentGameIndex/tears down the menu items. Both entries are added when a game launches and removed via pd->system->removeAllMenuItems() when returning to the in-app menu (either path), since neither makes sense while already in the game-select menu.

Toggling the checkbox needed one more fix, found by direct user report before it shipped: flipping it OFF could leave the grid lines “burned in” on screen instead of disappearing, for the same reason menu.h’s own onResume hook exists at all (see its own comment) - a game that skips its own redraw on frames where nothing changed (an isInvalid-style dirty-flag optimization, only some games have it) won’t naturally paint over pixels nothing else is touching, so a grid line drawn on a now-static frame just stays there once the overlay stops being redrawn on top of it, exactly the same “frozen screen with stale pixels” failure gamesMain.c’s own quit- dialog-resume path already forces a redraw to avoid. Fixed the same way: pixelGridMenuCallback() calls the current game’s own onResume() (if it has one) right after reading back the new checkbox value, forcing one real full redraw regardless of which direction the toggle went - cheap enough (a rare, player-initiated toggle) not to bother special-casing ON (which never had the staleness problem, since it only adds pixels) vs OFF.

Audio: one PDSynth (not cglpPlaydate.c’s own SYNTH_COUNT==4 round-robin pool, needed there for crisp-game-lib’s own overlapping multi- note effects - TinyJoypad’s original hardware is a single piezo buzzer, so every shim’s Sound()/playTone() call already expects exactly one tone at a time, matching every other port’s own single-voice choice) with setWaveform(synth, kWaveformSquare) - matching every other port’s own square-wave finding (see the SDL3 bug entry below) for free, as a real built-in oscillator shape rather than hand-written sample math. Playdate’s own playNote(synth, freq, vel, len, when) takes a duration and auto-stops itself - md_playTone() just calls it directly with when=0 (“now”), no manual gFrameCounter/gToneStopFrame bookkeeping needed at all (unlike every SDL port’s own md_updateAudio(), which exists only for that bookkeeping there). gFrameCounter itself still has to keep advancing regardless, though: obonoCoreShim.c’s own note-sequencer (the 3 obonoCoreShim-lineage games) schedules its own next-note timing as md_getFrameCounter() + (durationMs/1000)*MD_FRAMES_PER_SECOND - a shared gameworld constant fixed at 60 (correct for the SDL ports, which really do run at 60fps) this port can’t change. Real Playdate hardware caps out at 50fps for full-screen refreshes (a hardware/panel limit, confirmed by direct user correction after an earlier revision of this port requested MD_FRAMES_PER_SECOND directly) - pd->display->setRefreshRate(50) (a new PLAYDATE_REFRESH_RATE constant, not MD_FRAMES_PER_SECOND) asks for the real achievable rate instead of the wrong one, but that alone would leave a 60-vs-50 mismatch: advancing gFrameCounter by a flat +1 per real update() callback would make obonoCoreShim’s own music/timing run 60/50 = 1.2x too slow in real wall-clock time, since its own math still assumes 60 “frames” happen per real second. md_updateAudio() compensates with a fractional accumulator instead (gFrameCounterAccumulator += MD_FRAMES_PER_SECOND / PLAYDATE_REFRESH_RATE every real callback, incrementing the real integer counter only once that accumulates past a whole number) - md_getFrameCounter() ends up advancing at the same real- time rate obonoCoreShim’s own math already assumes, regardless of the mismatch between the two frame rates.

Bugs found doing this port (both genuine latent bugs in shared gameworld/ code, not anything Playdate-specific - just the first port to expose them):

Verified working, not just “it compiles”: built and linked cleanly (simulator DLL, TARGET_SIMULATOR/MinGW), packed into a .pdx (including the bundled thumbnails/*.png, auto-converted by pdc) by the SDK’s own pdc tool, and confirmed via real screenshots of PlaydateSimulator.exe actually running it end to end: the menu (title, hint line, the alphabetized/numbered/paginated list - “01. 2048” first, digits sort before letters, matching gameworld/menu.c’s own alphabetization convention exactly - a live “PAGE 1/4” indicator, the selection cursor, and the selected game’s own real gameplay thumbnail + “BY OBONO” author caption, vertically centered in the list area, updating live as the selection moves between games) and actual gameplay (2048, launched via the A button, its board correctly filling the panel at GAME_SCALE).

avrCompat.h - the dialect shim that makes reusing the ported C tractable

uint8_t/int8_t/uint16_t/etc are deliberately aliased to plain int, exactly as in the Vircon32 build - load-bearing, not a simplification to revert: dozens of already-fixed truncation/wraparound/sentinel bugs across the ported games’ own history depend on no implicit byte-narrowing ever happening. PROGMEM/pgm_read_*/memcpy_P are ordinary flat-memory access (same as the Vircon32 build). Also provides max/min macros, a portable itoa() (not standard/not on every libc), and the shared arand() helper (non-negative-clamped rand() % n, matching every ported game’s own RNG-range-mismatch fix).

Rendering model

One persistent 640x360 SDL_Surface (gScreen) - not a small 128x64 “OLED” framebuffer scaled up at present time. This matches the Vircon32 build’s own real-screen-space model (that build’s own md_drawColumn() draws directly at final scaled screen coordinates too, never through a separate small framebuffer) rather than the more “obvious” tiny-canvas design, for two concrete reasons found while building this:

  1. md_drawSolidRect()’s callers (the quit-confirmation dialog) and the BIOS-font menu text both already assume real 640x360 screen-space coordinates, matching the Vircon32 build’s own dialog code exactly - using one shared canvas for game columns, dialog rects, and menu text avoids a second coordinate space entirely.
  2. Skipping a frame’s redraw and having the previous frame’s pixels simply still be on screen (obonoCoreShim’s own isInvalid-gated skip; the quit-dialog’s “game update() not called this frame” behavior) requires a genuinely persistent surface - an SDL_Renderer backbuffer’s contents aren’t guaranteed to survive across SDL_RenderPresent() calls on every backend, so this has to be real CPU-side memory the game world keeps alive itself.

md_drawColumn(col, page, value) masks value &= 0xFF (the same byte- truncation fix the Vircon32 build needed, since avrCompat.h’s no- narrowing ints mean upstream shift/OR sprite-compositing code can leave stray high bits set) then draws each set bit as a real GAME_SCALE x GAME_SCALE (5x5) filled rect - no texture atlas needed (SDL has neither of Vircon32’s GPU constraints: no CPU-writable-framebuffer restriction, no 1024x1024 texture-size cap that forced that build’s own atlas design).

Final window-fit scaling is handled entirely by SDL_SetRenderLogicalPresentation() (set once, at init) rather than any manual scale/offset math or a resize-event watcher - it re-derives the fit on every present.

biosFont.h reproduces the actual Vircon32 BIOS font (10x20px, codepage 1252, 256 glyphs) as raw glyph-column data, extracted from the real Vircon32 BIOS asset (Font 10x20 Bios Vircon32.png, authored by “Carra”, the Vircon32 project’s own author - also published on OpenGameArt.org) via a one-off Python/ImageMagick extraction, not hand-transcribed - per direct user request to match the Vircon32 BIOS specifically rather than reuse one of the in-project game fonts (menuFont.h’s 6x8 font, still used by the handful of games - Oroboros/Run Dude Run/Dino Game - that need their own page-aligned text). Drawn via md_drawColumnPixels() (an absolute-pixel-row primitive), since 20px-tall glyphs don’t line up with an 8px SSD1306 “page” the way md_drawColumn() assumes.

Thumbnails

assets/thumbnails/thumb_00.bmp .. thumb_32.bmp (256x128, cropped from a real gameplay screenshot’s 640x320 game area, indexed by registration order - the same order menuGameList.c’s own addGame() calls happen in, which is also what menu.c’s displayOrder[] indirection resolves an alphabetized menu position back to before calling md_drawGameThumbnail()). Generated via -ms (see “CLI parameters” below) plus an ImageMagick crop/resize pass.

Embedded in the binary, not loaded from disk at runtime. tools/gen_thumbnails.py reads every assets/thumbnails/thumb_NN.bmp and emits assets/thumbnails/thumbnailData.h - a generated (not hand-edited) header holding each file’s raw bytes as a static const unsigned char[], plus a gThumbnailBlobs[] lookup table of {data, len} pairs. sdlBackend.c on both SDL ports #includes it directly (each port’s own CMakeLists.txt adds assets/thumbnails/ to its include path) and decodes each blob in memory at first use via SDL_IOFromConstMem() + SDL_LoadBMP_IO() (SDL3) / SDL_RWFromConstMem() + SDL_LoadBMP_RW() (SDL2) - same BMP decoder either way, just fed a memory buffer instead of a file path. thumbnailsProbeIfNeeded() still stops at the first blob that fails to decode, preserving the old disk-probe’s “future 34th game with no thumbnail yet is a silent no-op” behavior, even though the blob count is now compile-time-known rather than discovered by a missing-file check. This replaced an earlier design (loading SDL_LoadBMP() from <exe-dir>/assets/thumbnails/, resolved via SDL_GetBasePath(), with CMakeLists.txt copying assets/ next to the built exe after every build) on direct user request - the built exe is now fully self-contained with no sibling assets/ directory to ship or find, so that post-build copy step was removed from both ports’ own CMakeLists.txt. The source .bmp files themselves are unchanged and still live in assets/ thumbnails/ (also still what tools/gen_thumbnails.py reads from) - only how the platform layer gets the bytes into memory changed. Re-run the script whenever a thumbnail is added, removed, or regenerated; its output is derived, so it isn’t meant to be hand-edited.

Unlike the Vircon32 build (which needed a 2nd atlas texture once its first 4x8 grid hit the 1024x1024 texture-size cap), there’s no size constraint here to design around - each thumbnail is just its own independently-decoded SDL_Surface.

Presentation effects: glow, CRT scanlines, pixel-grid

Three self-contained, reusable platform-side modules (glowEffect.h/.c, crtEffect.h/.c, pixelGridEffect.h/.c - each freely reusable by any future SDL3 project, no dependency on this project’s own gameworld/machineDependent split beyond “renders through an SDL_Renderer”), cycled through via a single button (BUTTON_GLOWSWITCH/G/gamepad West) in the exact same 5-state sequence as crisp-game-lib-portable-sdl’s own cglpSDL3.c (ButX handler): none → pixel-grid+glow → pixel-grid alone → CRT alone → glow alone → back to none. Pixel-grid and CRT are mutually exclusive by design (both are “what kind of display is this” choices); glow is independent and can combine with pixel-grid but, in this same cycle, never with CRT - this is inherited unchanged from cglp’s own state machine, not a new design. All three only ever apply during actual gameplay, not the menu screen (md_setInGame(), called once/frame from gamesMain_dispatchFrame(), reports this to the platform side, matching cglp’s own !isInMenu gate on the same three effects).

None of the three are literal ports of cglp’s own implementations - cglp’s own glow (applyGlowToRect/applyGlowToCharacterPixel) and pixel- grid code both assume its own per-character-rect rendering model (a glow border drawn around each individual on-screen “character” object, a grid line spaced relative to its own wscale window-zoom factor) which doesn’t translate to this project’s column-blit rendering model (there is no per-object rect list to hook a per-object glow into) or its own much larger fixed GAME_SCALE (applying cglp’s literal pixel-grid spacing formula unchanged at GAME_SCALE=5 produces a checkerboard block-out, not thin per-pixel outlines - confirmed directly via a test capture, see “Bugs found” below). Each was redesigned around what this project’s own rendering model actually provides:

Dialect conversion

The mechanical fixes every games/*.c/shim file needed, converting from Vircon32’s dialect (see tinyjoypad_vircon32’s own VIRCON32_C_DIALECT.md for the full list of what that dialect restricts/extends) back to standard C:

  1. int[N] name;int name[N]; (and the 2D form) - the single most common fix.
  2. struct Foo {...}; used bare afterward as Foo x;typedef struct {...} Foo; (drop the redundant tag) - affected roughly 19 of 33 game files.
  3. int*/int[N] used for text strings → char*/char[N] - Vircon32 strings are int[] (one 32-bit word per character); this is a genuine runtime-correctness fix, not just a compile nicety (a raw int* receiving a C string literal reinterprets its bytes as garbage ints) - see “Bugs found” below, this was found the hard way across several games in the first porting wave before later waves’ own agents learned to self-check for it.
  4. Vircon32-dialect function-pointer typedefs (typedef void(int,int*) Name;, with the * re-added at each use site) → real C typedefs (typedef void (*Name)(int,int*);, with the * living in the typedef itself, not at each use site) - affects GameFunc/DrawFunc in menu.h/obonoCoreShim.h.
  5. No forward-declaration-ordering changes needed - Vircon32 requires strict definition-before-use (no linker); standard C is more permissive, so the existing carefully-ordered code compiles as-is.
  6. No un-doing of already-applied Vircon32 restrictions (avoided ternary/switch/binary-literals/etc in various spots because Vircon32’s dialect doesn’t support them) - all of that is also valid standard C, so left alone rather than “restored” for no benefit.

Porting process

Phases 0-3 (skeleton, SDL backend, a handful of proof-of-concept games, the menu) were built by hand, proving the dialect-conversion recipe and the rendering-model decisions above against real, varied code before scaling. Phase 4 (the remaining ~30 games) was dispatched to background agents in waves, grouped by shim lineage - per the user’s explicit go-ahead once the recipe was proven - with every agent’s claimed verification independently re-checked afterward (line counts, structural greps for leftover Vircon32-dialect syntax, numeric data-table diffs against the Vircon32 source, a class-keyword-absence check for the hardest C++-flattened files) rather than trusted at face value - this is what actually caught the bugs below, not the agents’ own self-reports.

Bugs found during this port (SDL3-specific - see the sibling project for game-logic bugs)

These are bugs in the porting process itself or the new platform layer - not in any game’s own logic, which was already correct C inherited from the Vircon32 build. Every game-logic bug (the AVR-vs- Vircon32 dialect bug family, real gameplay bugs found via play) lives in tinyjoypad_vircon32/CLAUDE.md instead, since it’s the same C in both projects.

CLI parameters

Modeled on cglpSDL3.c’s own flag set (see its printHelp()), adapted: -w/-h (window size), -f (fullscreen), -ns (no audio), -fps (live FPS overlay, BIOS-font-rendered top-left corner), -nd (uncapped framerate - toggles vsync off, rather than cglp’s own manual SDL_Delay-based pacing, since vsync already provides correct pacing when enabled and this project had no reason to duplicate that logic), -s (force software rendering - added later, on direct user request; see below), -list, -g <NAME> (direct launch, case-insensitive), -ms (batch screenshot every game - also the mechanism thumbnails are generated from), -joy (write a .joy title-stub file per game, mirroring cglp’s own .cgl files under this project’s own extension), and a .joy file itself as a positional argument (extracts the game title from the filename, matching cglp’s own filename-only convention - never reads the file’s actual content). Deliberately not carried over: -nsd (cglp’s own “no scaled drawing” toggle ties into its glow/CRT pipeline in a way this project’s own effect design has no equivalent knob for). cglp’s own -a (force hardware-accelerated) also has no equivalent, but for the opposite reason from -s’s own existence: hardware-accelerated is already this backend’s own default (SDL_CreateRenderer( window, NULL) on SDL3 auto-picks its best-available driver; SDL_RENDERER_ACCELERATED is requested explicitly on SDL2), so there’s nothing left for -a to force.

-s requests SDL’s own built-in CPU rasterizer instead of that default - SDL_SOFTWARE_RENDERER (SDL_CreateRenderer(window, SDL_SOFTWARE_RENDERER), SDL3’s own defined driver-name string, literally "software") on SDL3; SDL_RENDERER_SOFTWARE swapped in for SDL_RENDERER_ACCELERATED in the renderer-creation flags (SDL2 has no NULL/”best available” shorthand the way SDL3 does) on SDL2. Both routed through a new sdlBackend_setSoftwareRendering() setter (matching the existing setWindowSize()/setFullscreen()/ setVsync() pattern - CLI parsing/ownership stays in main.c, sdlBackend.c only exposes setters called before sdlBackend_init()). Confirmed via the existing renderer-name startup log line (sdlBackend initialized: renderer=...) - reports software with -s on both ports, direct3d11/ direct3d (unchanged) without it.

Start quits instead of returning to the menu, when launched via -g/.joy

Cross-checked directly against cglpSDL3.c’s own Back-button handling (if (!isInMenu && (startgame[0] == 0)) goToMenu(); else quit = 1;, startgame being the parsed -g/.cgl-file title, never cleared again once a launch succeeds) on direct user request - confirmed cglp has no quit-confirmation dialog at all, and quits immediately in this case rather than offering any way back to a menu that was never shown in the first place. This project’s own dialog exists specifically because there IS normally a menu to protect an accidental Start-press from leaving (matching the sibling Vircon32 build’s own dialog) - but when a game was reached via -g <NAME> or a .joy file argument instead of the menu, there’s no menu state to protect; skipping straight to quit (no dialog) on the first Start press is the correct equivalent here, not “show the dialog, then quit instead of returning to the menu” (which would still interrupt gameplay with an extra confirmation step cglp’s own reference behavior never has either).

Implemented as a new gLaunchedDirectly flag in gamesMain.c, set once via gamesMain_setLaunchedDirectly() (gamesMain.h) - called from each SDL port’s own main.c, right after the real gamesMain_launchGameDirect() call in the -g/.joy branch specifically, NOT the other call site -ms’s runBatchScreenshots() uses (that path never reaches gamesMain_dispatchFrame()’s own interactive loop at all, so the flag would be meaningless there). gamesMain_dispatchFrame()’s own Start- button branch checks it before falling through to the normal confirmingQuit path; never cleared back to false afterward, matching startgame’s own permanence in cglp - quitting is the only way out of this mode by design, so there’s no scenario needing to un-set it.

Quitting itself needed one genuinely new cross-cutting piece: machineDependent.h had no “game world asks the platform to exit” concept at all before this (Vircon32 never needed one - no real OS process to quit). Added md_requestQuit(), implemented on both SDL ports by setting the same gQuit flag sdlBackend_pollEvents() already sets on a real window-close/ButQuit event - sdlBackend_shouldQuit() can’t tell the two causes apart and doesn’t need to. The Playdate port needed a real (no-op) stub too, same reasoning as md_setFpsOverlayShowing() above: it compiles shared gamesMain.c regardless of whether its own main.c ever calls into this particular path (no CLI/-g/.joy there at all, so it never will) - real Playdate hardware has no “quit the app” concept to begin with, so the stub is a genuine no-op, not a stand-in for some equivalent that port is missing.

Status

All 33 games ported, verified, and wired into the menu on all three ports (src/sdl3/, src/sdl2/, src/playdate/); the menu shows real gameplay thumbnails on every port. CLI parameters, FPS display, and the batch- screenshot tool are in place on both SDL ports; all three presentation effects (glow, CRT scanlines, pixel-grid) are implemented there and cycle correctly via a single button, gated to gameplay only and skipped over the quit-confirmation dialog’s own rect specifically (md_setDialogShowing()) and the -fps overlay’s own rect (md_setFpsOverlayShowing(), same re-composite-on-top technique, added later on direct user request since the overlay reads a lot less useful blurred/scanlined along with the actual gameplay) while still applying to the rest of the frozen screen behind either. Packaging is done: both SDL ports now embed their thumbnails directly into the exe at compile time (tools/gen_thumbnails.py -> assets/thumbnails/ thumbnailData.h, see “Thumbnails” above - no more post-build assets/ copy step, no sibling directory needed alongside the built exe), and the Playdate port has its own pdc-driven .pdx packaging. The F3/PageUp/ PageDown/S keybinds (fullscreen, volume, mute - see sdlBackend_pollEvents()) are live-wired on both SDL ports, not just declared in the keybind table; each change is logged to the console since there’s no on-screen indicator for any of the three. The Playdate port has its own from-scratch paginated menu (see “The Playdate port” above), its own pixel-grid-only effect toggle plus a “Menu” entry exposed through Playdate’s own system menu rather than a spare button, and its own 50fps- compensated audio/frame-counter handling. Not yet done: nothing currently tracked - revisit this section as new work starts.

References