gamebuino_classic_sdl

Gamebuino Classic games ported to SDL2, SDL3 and Playdate selectable in one convient menu


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

Gamebuino Classic → vanilla C/SDL port

Goal

Port c:\github\gamebuino_classic_vircon32 (99 Gamebuino Classic games behind one shared menu, targeting the Vircon32 fantasy console) to real, vanilla C + SDL/Playdate - the same relationship Tinyjoypad_SDL has to tinyjoypad_vircon32. Same overall architecture as Tinyjoypad_SDL (gameworld/platform translation-unit split, CLI flags, initialization sequence, SDL2/SDL3/Playdate multi-port structure) - but built on this project’s own gamebuinoShim/menu system (Gamebuino Classic’s own 84x48 display, 7 buttons, EEPROM) instead of TinyJoypad’s OLED/simpler-input system. Prompted directly by the user, who named Tinyjoypad_SDL as the exact template to follow and separately authorized using background agents for the bulk game-porting work.

All 99 games are now shipped on all three ports (SDL3, SDL2, Playdate)

Directory layout / multi-port structure

Mirrors Tinyjoypad_SDL exactly - no repo-wide top-level build, one fully self-contained CMake project per port:

Build from inside a port directory (e.g. cd src/sdl3 && cmake -B build -G Ninja && cmake --build build), same as Tinyjoypad_SDL.

Translation-unit boundary

Every games/*.c file, plus gamebuinoShim.c/eepromShim.c/menu.c/ menuGameList.c/gamesMain.c, is its own separate translation unit - gamesMain.c never #includes any of their .c bodies (unlike the sibling Vircon32 build, which stitches every game into one TU via #include in main.c). This is why every games/game*.c file needs its own #include "avrCompat.h" / "machineDependent.h" / "gamebuinoShim.h" at the top - a real, easy mistake made and caught during this project’s own first hand-port (Pong Solo initially failed to compile with dozens of implicit declaration of function errors from omitting these), and one every one of the dispatched batch-porting agents had to be told explicitly up front for the rest of this project’s history.

Machine-dependent facts specific to this project (vs. TinyJoypad/Vircon32)

The SDL2/SDL3 menu draws with the real Vircon32 BIOS font (biosFont.h), copied byte-for-byte verbatim from the sibling Tinyjoypad_SDL project - per a direct user request (“the menu needs to use same font as tinyjoypad menu’s font”). Playdate’s own menu uses the real Playdate SDK’s own native system font instead - a from-scratch, Playdate-specific menu design, not a biosFont.h port (see “The Playdate port” below).

Cross-cutting toggle design: what needs gameworld awareness vs. stays backend-only

Real Gamebuino button mapping (gamebuinoSDL3.h/gamebuinoSDL2.h)

CInput’s own SButtons struct (copied verbatim from Tinyjoypad_SDL, fully generic) has exactly one field per real gamepad face/shoulder/ trigger button (ButA/ButB/ButX/ButY/ButLB/ButRB/ButLT/ ButRT) - not a dedicated “ButC”. Real Gamebuino Button C is mapped onto ButX; real-gray-color is ButRB; the combined effect cycle is ButLB; real volume down/up are ButLT/ButRT.

The pixel-grid/glow/CRT cycle went through several real design iterations before landing here, each a direct, explicit user request, not a single up-front design: pixel-grid started as this project’s own original, already-shipped standalone toggle (ported ahead of glow/CRT, its own dedicated button); glow/CRT (glowEffect.h/crtEffect.h, copied verbatim from Tinyjoypad_SDL) were then added on a separate button, first attempting to faithfully match that project’s own curated 5-state cycle (none -> pixel-grid+glow -> pixel-grid -> CRT -> glow -> none) while deliberately keeping pixel-grid’s own already-shipped button untouched; then, per a direct follow-up, the two were unified onto one button and one real 3-bit counter (gEffectState) enumerating all 8 combinations of pixel-grid/glow/CRT (a genuine superset of Tinyjoypad_SDL’s own curated subset, which never combines pixel-grid with CRT, for instance) - gPixelGridEnabled/gGlowEnabled/gCrtEnabled are always recomputed from gEffectState’s own 3 bits on every press, never tracked independently. This freed pixel-grid’s own original button/slot, which a final follow-up request repurposed for real analog volume control instead (see below) - pixel-grid itself has no separate toggle button anymore, it is purely one of the three bits reachable through the combined cycle.

Real volume control (gVolume, a float scaling AUDIO_AMPLITUDE in the mixer, 0.05f step per press, independent of gMuted) was added on direct request once the project was checked against Tinyjoypad_SDL and found missing this exact feature (that project’s own PageDown/PageUp, ButLB/ButRB) - remapped here onto the two real analog shoulder triggers (ButLT/ButRT, L2/R2) instead, since ButLB/ButRB were already spoken for by the effect cycle and real-gray-color respectively. Gamepad-only, per direct request - no keyboard key was added for it.

Final keyboard layout: X=A, C=B, D=Gamebuino Button C, S=mute, G=effect cycle (L/W/Z also work as alternates - L’s own original meaning, pixel-grid, now reachable through the combined cycle instead), R=real-gray-color (V also works as an alternate), Escape/Enter= Start. BUTTON_DARKSWITCH is kept defined only because CInput.c’s own keyboard-event switch references it - D is already claimed by Button C, so it’s routed to Enter instead, a genuine second keyboard shortcut for Start, not dead code.

Real gamepad layout (Xbox/Switch-style face buttons): South=A, East=B, West=C, North=mute, Left shoulder(L1)=effect cycle, Right shoulder(R1)= real-gray-color, Left/Right trigger(L2/R2)=volume down/up, Start/Back= Start.

A real font-table transcription bug, found and fixed via direct user report

While retargeting gamebuinoShim.c from the Vircon32 build, this session’s own earlier reading of the real gbFont3x5/gbFont3x3/ gbFont5x7 data tables had fallen out of context across a conversation compaction - when writing the new project’s own copy of these tables, they were reconstructed from memory instead of being re-read from the source, producing genuinely fabricated data. gbFont5x7 happened to still render two digits recognizably by pure coincidence; gbFont3x5 (Agaruino’s own “AGARUINO !” title text) rendered visibly garbled, which is what surfaced it via a direct user report. Fixed by re-extracting the real decimal data directly from gamebuino_classic_vircon32/src/gamebuinoShim.c via a small Python script (never hand-retyped) and diff-verifying byte-for-byte against the source for all three tables.

This is a durable, project-wide lesson, not a one-off fix: every game ported into this project - including by every background agent across every batch - must re-read the real source file fresh for any data array (sprite bitmaps, level tables, font/lookup tables) and copy/script it verbatim, never retype or recall it from memory, then diff-verify it byte-for-byte against the source before trusting it. Saved to this session’s own persistent memory (font-tables-must-be-copied-not- recalled.md) so it survives future compactions too.

Dialect conversion recipe (Vircon32 C dialect -> standard C)

Confirmed to transfer directly from Tinyjoypad_SDL’s own established recipe, since both projects port from the same Vircon32 C dialect:

  1. int[N] name; -> int name[N]; (array-declaration order) - applies to every array, including data tables.
  2. struct Foo { ... }; Foo x; -> typedef struct { ... } Foo; - so pointer parameters (Foo* f) and plain declarations both work with no struct keyword at the use site.
  3. int*/int[N] used for text strings -> char*/char[N] - a genuine runtime-correctness fix (Vircon32 strings are one 32-bit word per character). Bitmap/font/level data tables stay int[]/int* - genuinely numeric byte tables, not text.
  4. Vircon32 function-pointer typedefs (typedef void(void) GameFunc;, * re-added at each use site) -> real C typedefs (typedef void (*GameFunc)( void );, * lives in the typedef itself).
  5. Every games/*.c file needs its own #includes (see “Translation-unit boundary” above) - the one part of this recipe with no Vircon32-side equivalent to translate from.
  6. A file that uses none of the above needs zero changes and copies over as real standard C unmodified.
  7. No ternary operator in the Vircon32 dialect - but standard C does have one, so unlike the sibling Vircon32 project (which has to expand every a?b:c into if/else), this direction of the conversion is free: any already-explicit if/else in the Vircon32 source can stay as-is, or be tightened into a ternary here, purely a style choice.

Batch porting workflow (all 99 games)

Used the same parallel-isolated-worktree agent workflow both sibling projects have already established: one background agent per batch of ~10-15 games, each in its own git worktree (Agent tool, isolation: "worktree", run_in_background: true), given the full dialect-conversion recipe and the font-table-verbatim-copy lesson above explicitly in its own prompt (never assumed inherited), each mechanically converting its own batch’s real Vircon32 source into standard C and reporting back exact addGame()/games.h lines. The orchestrating session integrated one batch at a time (copy files, add declarations/ registrations, rebuild, spot-verify, commit) to avoid parallel-edit conflicts on the shared menuGameList.c/games.h files - eight batches total to reach all 99.

menuGameList.c’s own registration order was found to have drifted from the sibling Vircon32 project’s own real order (ad-hoc batch- integration order instead) - caught by direct user review (“make sure the addgame calls order at the end match sibling project”) and fixed via a full rewrite matching the source project’s exact sequence, including all 11 markUnfinished() calls (Bang! Bang!, Save Princesse, MotoCross, No Name Platform Game, Ralph, MyRPG, DarkShmup, PinBall, Robot, Cruiser, Community RPG) with their own real reason strings.

A prompt-injection concern was raised and independently self-verified, not silently trusted or dismissed: two separate background agents during the porting batches reported their own scratchpad conversion scripts being silently overwritten mid-task, each accompanied by a system-reminder falsely attributing the change to “the user or a linter.” Both agents refused to comply with any embedded instruction and self-verified their own final output using fresh logic instead of trusting the altered script. Surfaced directly to the user rather than hidden; no forensic root cause was ever found, but no corrupted output made it into the shipped result either, since both agents’ own real deliverables were independently re-verified before integration.

Screenshot/verification testing conventions

Established the hard way, worth stating explicitly for any future work on this project:

The .gbu convention

This project’s own naming choice (not inherited from Tinyjoypad_SDL, which uses .joy) - -gbu CLI flag, writeGbuFiles(), .gbu extension detection for positional-arg direct-launch. Purely a file-extension/ naming difference; the mechanism (one stub file per registered game, an external frontend’s own per-game launch target) is otherwise identical to the sibling’s .joy design.

Generating metadata (metadata/screenshots/, metadata/gbu/)

A deliberate, direct correction from the user, worth remembering: an earlier attempt added code to main.c so -ms/-gbu would write directly into metadata/ (new METADATA_DIR/ensureDir() machinery) - told directly this was wrong (“it should have been left alone you just needed to run the binary and copy the files to directory”). Reverted all of that path/directory-creation code; -ms/-gbu still write to the current working directory exactly like Tinyjoypad_SDL’s own identical flags always have. The real 99 screenshots + 99 .gbu files in metadata/ were generated by manually running the built binary from inside src/sdl3/build/ and copying the output files into metadata/ by hand - a one-off manual step, not build automation, matching this project’s own established “no checked-in generator for a one-off asset- staging step” precedent (see “Thumbnail generation” below for the identical precedent applied to assets/thumbnails/).

Thumbnail generation

tools/gen_thumbnails.py is a near-verbatim port of Tinyjoypad_SDL’s own script of the same name/purpose: probes assets/thumbnails/ thumb_00.bmp, thumb_01.bmp, … sequentially (stopping at the first missing index), embeds each as a C byte array, and writes assets/thumbnails/thumbnailData.h - a single shared file both src/sdl3/ and src/sdl2/ #include via their own target_include_directories() pointing at ../../assets/thumbnails (that CMake wiring was actually present from this project’s own earliest scaffolding, unused until the real restructuring below landed).

thumb_NN.bmp is 224x128, not MD_THUMBNAIL_WIDTH x MD_THUMBNAIL_HEIGHT (256x128) - a real mistake already made once, when twelve thumbnails (registration indices 99-110) were cut at 256x128 by reusing the sibling Vircon32 project’s own atlas cell size. The real LCD crop is 588x336, whose 1.75 aspect only survives a uniform downscale - 2.625, giving 224x128; 256x128 stretches it to 2.0. And because md_drawGameThumbnail() blits with SDL_BlitSurface, which does not scale (the dst rect’s w/h are ignored), an oversized thumbnail is not squeezed back into the slot either - it just renders 32px wider than every other one. MD_THUMBNAIL_WIDTH/_HEIGHT describes the menu’s own layout box (what the author/info captions centre themselves on), not the asset. The exact recipe - crop (26,12)+588x336, Image.NEAREST to 224x128, save 24-bit - reproduces any existing thumbnail from its own screenshot pixel-for-pixel, and is written out in tools/gen_thumbnails.py’s own header comment.

This shared-file structure was NOT how this project’s own thumbnails were first implemented - an earlier pass had gen_thumbnails.py crop/ downsample directly from metadata/screenshots/<TITLE>.bmp (the raw 640x360 gameplay captures) and write two separate, fully-duplicated thumbnailData.h copies straight into src/sdl3/ and src/sdl2/ - it worked (both ports’ own target_include_directories() already lists ${PROJECT_SOURCE_DIR} itself, so the per-port copy resolved first), but didn’t match the sibling project’s own real convention. Corrected via a direct user catch (“normally … tools should generate from assets\thumbnails like on tinyjoypad_sdl and … assets\thumbnails contain the bmp files”) - assets/thumbnails/thumb_NN.bmp (99 files, a crop + point-sample of each real metadata/screenshots/<TITLE>.bmp capture down to MD_THUMBNAIL_WIDTHxHEIGHT, 256x128, at the real LCD sub-rectangle within each 640x360 capture - 588x336 at pixel offset (26,12)) now exist as real, checked-in staging files (that crop step itself has no checked-in generator, matching the sibling’s own identical precedent - a one-off manual step, redone by hand if a thumbnail is ever recaptured), gen_thumbnails.py was rewritten to the sibling’s own exact probe-and-embed shape, and the two old per-port thumbnailData.h copies were deleted so the shared file at assets/thumbnails/ is the only one that exists.

Playdate’s own thumbnails are a genuinely different, separate pipeline

A real, easy gitignore trap hit twice while staging these: this project’s own .gitignore globally ignores both *.bmp and *.png (everywhere - build output, batch-screenshot captures, etc.), with per-directory !-exceptions carving out the real, checked-in assets (assets/**/*.png, assets/**/*.bmp, metadata/screenshots/**/*.bmp, metadata/screenshots/**/*.png, src/playdate/Source/thumbnails/**/ *.png) - each new checked-in image directory added to this project needed its own new exception line, and it’s easy to add the files/ directory without remembering the matching exception (caught directly by the user twice: once for the Playdate agent’s own recovered thumbnails, once for assets/thumbnails/ itself - “assets should not be ignored it should be staged”).

Grayscale rendering (GB_GRAY)

Real Gamebuino Classic hardware fakes a third shade on its strictly 1-bit display via a checkerboard dither that flips with the frame counter - this is GB_GRAY’s own unconditional default rendering on every port here, computed per-pixel directly in gamebuinoShim.c’s own gbDrawPixel(), needing zero special handling on any of the three backends (SDL2/SDL3’s true-color canvas, or Playdate’s real 1-bit panel).

gbRealGrayColor is a real, optional enhancement past real hardware - when on, GB_GRAY content renders as a genuine, solid, flat gray instead of the flickering dither:

Thumbnail generation now deliberately captures each port’s own real visual truth, not one shared image for both - per direct request. Since Playdate’s own kColorGrey pattern-fill is visually identical to the checkerboard dither (gray off), never the flat solid gray SDL2/SDL3 show with it on (their own default), a genuinely accurate Playdate thumbnail needs a gray-off source capture, while SDL’s own thumbnails (and the README’s own screenshot display) correctly keep using a gray-on capture matching their own real default. A new -gray <0|1> CLI flag (main.c, both SDL ports, via a new gamesMain_setRealGrayColor() in gamesMain.h/.c) overrides gamesMain_init()’s own default for exactly this purpose - main.c never reaches into gamebuinoShim.c’s own gbRealGrayColor global directly, matching this project’s own established TU-boundary discipline. src/playdate/tools/gen_thumbnails.py’s own header comment documents the exact regeneration recipe (-ms -ns -nd -gray 0 into a scratch directory, crop from there, discard the temporary capture afterward - matching this project’s own “no checked-in generator for a one-off asset-staging step” precedent).

The Playdate port

Built by a background agent, twice - see “A lost-agent recovery” below for why. Genuinely different from SDL2/SDL3 in shape, not just API:

Building the Playdate port

cd src/playdate
cmake -B build -G Ninja           # Simulator target - produces GamebuinoClassic.pdx
cmake --build build

Requires the official Playdate SDK (PLAYDATE_SDK_PATH env var, or the CMake config’s own documented fallback location). Device builds use -DCMAKE_TOOLCHAIN_FILE=$PLAYDATE_SDK_PATH/C_API/buildsupport/arm.cmake with a separate build directory (see .github/workflows/build.yml’s own playdate matrix entry for the exact real CI invocation).

A lost-agent recovery

The first Playdate-porting background agent was killed mid-task when the IDE hosting this session was restarted - its task ID became untrackable and its own output file was never written to, confirmed via direct verification (not assumed) before treating the work as lost. Its own git worktree, however, was still on disk with real, substantial, well-reasoned progress: a 1156-line main.c (with real architectural decisions already made and documented in its own header comments - GAME_SCALE reasoning, the from-scratch menu design, the no-dialog choice), a complete CMakeLists.txt, pdxinfo, tools/gen_thumbnails.py, and all 99 real gameplay thumbnails already generated. Recovered by copying that content into the main tree and committing it as real WIP (safe to do, since this project’s own git history gets squashed to one commit as a final step anyway), then dispatching a second background agent explicitly instructed to read and continue that exact recovered work rather than restart - which is what produced the finished, verified port described above. The dead agent’s own orphaned worktree/branch (and, later, every other already-integrated batch’s own stale worktree/branch) were cleaned up via git worktree remove --force --force + git branch -D once confirmed fully redundant with what had already landed on master.

Continuous Integration

.github/workflows/build.yml - a full SDL2/SDL3/Playdate build matrix, modeled directly on Tinyjoypad_SDL’s own workflow of the same name and shape (same per-runsontype step structure for Windows/mingw, Linux, macOS, and the Playdate device build), adapted for this project’s own binary names and asset layout (metadata/gbu instead of metadata/joy files) - the appimage lane was dropped (this project ships no build-appimage.sh). The macOS icon-building steps (sips/iconutil, building a real .icns from metadata/icon.png into the .app bundle’s own Info.plist) and every LICENSE.txt copy step were initially dropped too, for lack of a real icon.png/LICENSE.txt at the time - both were added once the user supplied a real metadata/icon.png and a real LICENSE.txt existed (see “License” below), restoring this workflow to the sibling’s own exact shape in both respects.

A required field, per direct request, marks which of the 23 matrix entries actually gate the workflow’s own pass/fail status (continue-on-error: $) - every entry still runs and reports regardless. 7 are required: one canonical build per platform per SDL version (Windows x86_64, Ubuntu 24.04 x64, macOS 14 arm64, ×2 for SDL2/SDL3) plus the one Playdate device build. Every other variant (32-bit Windows, older Ubuntu, ARM runners, extra macOS versions/arches) is real coverage kept non-blocking, since they all build the identical source against a different compiler/libc/arch, not a different code path.

License

Copied the real, verbatim GPLv3 LICENSE.txt directly from either sibling project (confirmed byte-identical between Tinyjoypad_SDL’s and gamebuino_classic_vircon32’s own copies before copying) - the same real reasoning applies here: several shipped games (Agaruino, CrazyCar, Shufflepuck Cafe, Taquin, and others) are themselves GPLv3, making the combined executable a GPLv3 combined work regardless of this project’s own new code’s own license preference. See README.md’s own “License” section for the one known, unresolved concern (Firemen’s own upstream LICENSE.md says GPLv2-only, no “or later” clause, a real compatibility question inherited unmodified from the source Vircon32 project).

Five more games ported from the sibling Vircon32 project

Prompted directly, right after the sibling gamebuino_classic_vircon32 project’s own seventh discovery pass shipped them there: R.S.G. (deKay), Discovery (FirstKlaas), Bomberman (JohnAkerman), Bricks! (Drakker) and Wolfenduino 3D (jhhoward). 104 games now, plus the SOUND TEST tool.

Four of the five needed nothing beyond the standard dialect-conversion recipe above (array-declaration order, struct -> typedef struct, the three per-TU #includes, and int*/int[] -> char*/char[] for the handful of arrays that genuinely hold text - R.S.G.’s own button-icon glyph strings). All of it was applied by script rather than by hand, then diff-checked for leftover dialect syntax.

Wolfenduino 3D needed one genuine shared-shim addition. It is a raycaster that writes pixels straight into the framebuffer, exactly as its own real upstream does through _displayBuffer[] on real hardware. In the Vircon32 build every game is stitched into one translation unit, so gbFrameBuffer was visible without a declaration; here each game is its own TU, so gamebuinoShim.h now carries a real extern int gbFrameBuffer[ LCD_WIDTH * LCD_PAGES ]; alongside a comment spelling out the addressing ([x + (y/8)*LCDWIDTH], bit y%8). No other game touches it, and nothing else changed in the shim.

The two Vircon32-dialect hazards that shaped that port (its logical-shift >> and its 32-bit-only int) both carry over unchanged and correctly: WOLF_ASR() produces exactly what a real arithmetic shift produces, and WOLF_I16()/WOLF_I8() are still needed here, because avrCompat.h aliases the narrow AVR types to int on this side too.

Screenshot scripts added for all five (screenshotScriptFor() in both src/sdl3/main.c and src/sdl2/main.c, kept verbatim between the two). Three fit the generic tap loop; two needed custom sequences:

Every one of the timings was tuned against the actual captured image rather than assumed, and the first pass got three of them wrong in ways only the screenshot revealed: BOMBERMAN’s enemy deals 10 damage a tick on contact and had already killed the player (“You Died”), BRICKS!’ ball had gone up, bounced back past the stationary bar and been lost (the capture landed on the ball-loss explosion), and DISCOVERY’s ship was mid-explosion from a Klingon collision. All three shortened until the capture showed real, healthy play.

Verified by running the full -ms -nd -ns batch on both SDL3 and SDL2 (all 105 entries captured, no crash) and diffing the two ports’ captures pixel-for-pixel: BOMBERMAN, BRICKS! and WOLFENDUINO 3D are byte-identical between SDL2 and SDL3, and R.S.G./DISCOVERY differ only in their own arand()-driven values (opponent HP/MP, Klingon spawns), which is expected run-to-run variation rather than a port difference. The Playdate port builds clean too - it shares gameworld/ unmodified, and its own separate tools/gen_thumbnails.py regenerated all 105 Source/thumbnails/ thumb_NN.png from the same screenshots.

Metadata generated the documented way: -ms for the five metadata/screenshots/<TITLE>.bmp captures (plus .png copies), -gbu for the stubs, then the manual LCD crop (588x336 at (26,12)) point-sampled to 256x128 into assets/thumbnails/thumb_100.bmp..thumb_104.bmp, then tools/gen_thumbnails.py. A pre-existing gap was filled while there: metadata/gbu/ was missing SOUND TEST.gbu - every other registered game, markUnfinished() ones included, already had a stub. It is there now, so the set is one-per-registered-game again.

Six more games ported from the sibling Vircon32 project

Straight after the sibling gamebuino_classic_vircon32 project shipped them: Guess A Number (l_et_m), Walbloks (borkin8r), Snake (Sigma-Squared), Copter (by Anny - annyfm, genuinely unrelated to the already-shipped Clement83 Copter), Siena Town (Patryk Kuciński) and Super Crate Buino: Stone Edition (4LAR, a fork of the already-shipped Super Crate Buino). 110 games now, plus the SOUND TEST tool. MAX_GAMES raised 112 -> 120.

All six needed only the standard dialect-conversion recipe. Two small things worth noting for next time:

Screenshot scripts added for all six to both src/sdl3/main.c and src/sdl2/main.c (kept verbatim between them). Four needed custom sequences: Siena Town and Stone Edition both need an extra Button-A press through a menu the original game does not have (Stone Edition’s own added mode select), and Guess A Number and Copter both needed held input rather than taps - a held Up to walk the guess off zero before submitting it, and bursts of Button A to keep the helicopter airborne long enough for the distance score to climb.

Verified with the full -ms -nd -ns batch on SDL3 and SDL2 (all 111 entries, no crash) and a pixel diff between the two ports: Guess A Number, Walbloks and Copter are byte-identical; Snake Sigma, Siena Town and Stone Edition differ only in their own arand()-driven state (food/lane/enemy placement), confirmed still-valid gameplay by eye rather than assumed. Playdate builds clean and its own tools/gen_thumbnails.py regenerated all 111.

Toggle status badges and a transient change toast

Ported from the sibling Vircon32 build (see that project’s own CLAUDE.md for the original), but deliberately NOT one-to-one: this project has more runtime state than that one does, so the row is five badges rather than three and one of the toasts has to describe a combination rather than a single named toggle.

The badges (md_drawToggleStatusIcons() in both sdlBackend.c copies, called from gameworld/menu.c’s own draw): SND / GRAY / GRID / GLOW / CRT across the menu’s own top-right corner, laid out right-to-left from the screen edge so the row stays anchored whatever the labels are. A lit toggle draws in plain white, an unlit one in a new MD_COLOR_DARKGRAY (gDarkGrayPixel, 90/90/90) - so the row reads as lit/unlit at a glance instead of having to be read. gbRealGrayColor lives in the shared gameworld layer rather than the backend, so gamesMain.c mirrors it down via a new md_setRealGrayEnabled() beside the md_setInGame()/md_setDialogShowing() calls it already makes.

The toast (md_showStatusToast() / drawStatusToast()): a white-outlined black box at the bottom centre, up for 60 frames, drawn at the top of md_endFrame() - which on these ports means it lands on top of the game’s own finished frame, the menu and the quit dialog alike, since everything draws into the same persistent screen surface before the present.

The effect cycle needs its own message. gEffectState is one button stepping a 3-bit counter through all eight combinations of pixel-grid, glow and CRT, so “GRID ON” would be a lie half the time. showEffectToast() builds the message from whichever bits are actually set - "EFFECTS: GRID+CRT", "EFFECTS: NONE" - so what it says always matches what is on screen. This is the one place the two projects’ implementations genuinely differ rather than just differing in label count.

All the toggles are now ungated. Mute, gray and the effect cycle were each only read while a game was running, on the reasoning that flipping them in the menu would be an invisible no-op. With the badges showing their state that is no longer true, so all three are accepted everywhere.

Playdate needed its own design, not a copy. That port does not use gameworld/menu.c’s own menu_update() at all (it has a from-scratch menu), so md_drawToggleStatusIcons() is a deliberately empty stub there and a local menuDrawStatusIcon() draws the badge instead. Only one of the five toggles exists on that port - the pixel grid, flipped from the system menu’s own checkmark item - and 400x240 has nowhere near the width for the SDL word labels, so it is a single letter G, boxed when on and outlined when off (a 1-bit panel has no dim gray to fall back on). The toast matters more there than anywhere else: the checkmark is flipped inside the system menu, which then closes over the game, so without it there is no confirmation the press did anything at all. drawStatusToast() is called at the very end of update(), after the game’s own draw and the pixel-grid overlay, for the same “on top of everything” reason as md_endFrame() elsewhere.

Verified by capture on SDL3 (-ms -nd -ns, the menu’s own badge row with SND/GRAY lit and GRID/GLOW/CRT dim, plus a toast capture) and by the user directly on the Playdate Simulator.

A third GB_GRAY mode: real LCD persistence, modelled on Simbuino

Ported from the sibling Vircon32 build, which has the full writeup - what github\simbuino’s own LcdDevice.cs actually does (a two-sample moving average of each pixel’s own lit time, quantized to three levels), and why that collapses here to two bitwise ops on the byte columns both projects already blit:

gbRenderFramePersistent() in gameworld/gamebuinoShim.c therefore reuses gbRenderFrame()’s own two existing passes and both md_drawColumn()/md_drawColumnGray() backends unchanged, just fed different byte values. The only new state is gbPrevFrameBuffer[] plus gbLcdPersistencePrimed, which stops a stale previous frame - the menu’s, or another game’s - from being smeared into the first tick after the mode is switched on or a game is launched (gbBegin() clears it).

This is broader than gbRealGrayColor, not a second way to draw the same thing. That flag recolors pixels a game explicitly drew as GB_GRAY; this one smears whatever actually alternates - GB_GRAY’s own dither included, but equally a game’s own blinking sprite or invert flash. The two are mutually exclusive: LCD mode needs GB_GRAY to dither normally to have anything to average.

Port-specific plumbing this project needed on top of the shared shim change:

Verified by capturing B-Rally, whose road is a large GB_GRAY band, at -gray 0/1/2: two colors in mode 0 (the raw checkerboard), three in modes 1 and 2, with mode 2 additionally showing a one-tick gray trailing edge on the moving car - the smear that is the whole point. Badge row captured in all three states. SDL2, SDL3 and Playdate all build clean.

Status

Open items / next steps