Skip to content

Latest commit

 

History

History
1595 lines (1399 loc) · 114 KB

File metadata and controls

1595 lines (1399 loc) · 114 KB

GUme — Braino!

These rules apply to everyone changing this repository, human or agent. CONTRIBUTING.md covers how a change reaches the protected main and dev branches; this file covers what the change itself has to honour.

Two standing rules — do these without being asked

No AI attribution in commits -- and no AI in the names either. Never add Co-Authored-By: Claude, Co-Authored-By: naming any AI, "Generated with..." footers, or any trailer that credits a model or tool. This applies to commits, amends, squashes and PR bodies. The history here records a human author.

The same goes for the word itself. No claude, and no other model or vendor name, anywhere a contribution leaves a trace: branch names, commit subjects and bodies, PR titles and descriptions, file names, identifiers, comments and TODOs. A branch called claude/fix-the-thing records who typed rather than what changed, and unlike a session it is permanent -- it is in the merge commit, the pull request, and every clone, long after anyone remembers which tool was open that day. Name a branch for its work instead: feat/<game-id>, fix/<area>, docs/<topic>. If you find yourself on a branch that breaks this, rename it before you open the pull request (git branch -m <new-name>).

The CLAUDE.md files are the one exception, because that filename is how an agent finds this rulebook at all. The rule is about what a change carries, not about what the rulebook is called.

Docs are part of the change, not a follow-up. If a change alters behaviour, architecture, dependencies, screens, settings, the game list or the build, then in the same commit update README.md (feature set, game count, the flash/RAM figures from your own pio run, version), this file (architecture, invariants, build), AGENTS.md (agent protocol) and the relevant directory CLAUDE.md. A README claiming the wrong game count or a stale flash figure is a defect belonging to whoever last changed the thing it describes.

Run python tools/check_docs.py and python tools/check_boards.py before you commit. It fails if the version, the game count, the source-tree listing or the build figures have drifted, and if the About app has started restating facts instead of deriving them. These rules existed and the docs went stale anyway -- the README shipped claiming 23 games when there were 26, and the source listing missed three files that had been added. Asking for vigilance does not work at the end of a long change; the check does. It is not a substitute for reading the prose, only for the parts a machine can catch.

If your change alters a screen's layout, or adds or removes one, regenerate the mock-ups in the same commit: python tools/gen_screens.py. docs/screens/ kept images of the deleted Countries game for two releases, and the Settings picture showed a grid that no longer existed. A mock-up of a screen that is not there any more is a worse lie than a missing one.

Every file says who wrote it, and under what licence

The repository has been GPL-3.0-or-later since it was published, and LICENSE, NOTICE.md and the README all say so. None of that travels. A licence at the root of a repository is a claim about the repository; the thing that actually reaches a stranger is one file -- a .cpp pasted into a forum answer, a tool script copied into another tree, a page of documentation lifted into a wiki. Split from its repository, an unmarked file carries no author, no terms and no way back to either, and whoever took it is not being dishonest: they have nothing to go on.

So every text file that supports a comment carries the notice itself -- the SPDX licence identifier, the copyright holder, where the work came from, and what reuse requires. Source, headers, scripts, workflows, platformio.ini, the site template, the SVGs; documents carry it as a visible footer instead, because a reader lifting a paragraph out of a rendered page never sees an HTML comment. It grants nothing new and takes nothing back. It is the licence the project already had, written where it can be seen.

python tools/check_licenses.py is the check, and --fix is the fix. That is deliberate: a rule that has to be remembered 250 times is a rule that will be missed, which is the same reasoning as check_docs.py. It runs in CI on every pull request. Three things follow from it:

  • A new file gets a notice in the commit that adds it. Run --fix; do not hand-type one, and do not copy one from a neighbouring file with a different extension.
  • A generated file gets its notice from its generator. header_for() in check_licenses.py is the one definition of the wording and every generator imports it, so regenerating a table cannot quietly strip the notice and two copies of the header cannot drift apart.
  • The holder in a header is iamankushpandit and nothing else. A header that disagrees with LICENSE is worse than no header, because it is the one a downstream reader will rely on. The exemptions -- LICENSE itself, files with no comment syntax (JSON, binaries), and PlatformIO's stub READMEs -- are listed with their reasons at the top of the checker; add to that list only for a reason of the same kind, never for convenience.

The related rule this does not replace: trademark is not copyright. NOTICE.md keeps them apart, and a fork renames the product while keeping the attribution. See include/AppVersion.h.

The About app is user-facing documentation — keep it true

AboutApp is the only documentation most owners will ever read, and the only one they read while holding the device. It is part of the deliverable, not a credits screen. Update it in the same commit as the change it describes.

It was already six games out of date once, because it kept a hand-written list. The fix, and the standing rule, is to derive rather than restate:

About shows Derived from
Version BRAINO_VERSION
Game count and every game name/blurb AppRegistry playable apps
Board name BOARD_NAME
Wi-Fi status Board::hasWifiCredentials() / isWifiConnected()
Beacon status and advertised name BleBeacon::active() / configured()
Whether scores are being shared BleBeacon::configured().sharesActivity
Branch, commit and build time BuildStamp::branch() / commit() / builtAt()

If you are about to type a fact into About that the firmware already knows, read it from the firmware instead. Anything genuinely static — the credits, the privacy statements — must be re-read whenever the thing it describes changes.

No identifiers in this repository - the rule that cannot be broken

Nothing that identifies one physical device, one network or one person may ever be committed to this repository. Not in a tool file, not in a comment, not in a test fixture, not "temporarily", and not because a design needs it. If a workflow needs such a value, it lives on the machine that owns the hardware and it is gitignored.

This rule is written in the past tense of a failure. tools/board_registry.json mapped six boards' MAC addresses to the exact firmware environment each was running. It was tracked for three commits and shipped inside two release tarballs before anyone noticed. It was created deliberately, by an agent, as the sensible core of a tool that answers "which board is on which port" -- and being sensible is not the test.

Understand why this particular mistake is unrecoverable. A MAC is burned into eFuse. It cannot be changed, regenerated or rotated: it names that board for the life of the silicon. Deleting the file does not un-publish it, because git history keeps every version and clones, forks, the API and release tarballs all keep their copies. And the harm is worse than a bare MAC, because the file paired each one with the firmware that board runs -- which is a list of specific devices, in someone's home, and what is on them.

So:

  • Identify a board by Board::deviceId(), not by its MAC. The firmware generates it on first boot from the MAC, the clock, 64 bits of esp_random() and the time since boot, puts all of that through SHA-256, and keeps four bytes: R28T-9F3A2C71. The hash is what makes it safe -- it cannot be turned back into the MAC -- and the BoardProfile::idTag prefix says which model it is, which is a fact about a product rather than about a person's house. A factory reset issues a new one, which is the property a MAC can never have.
  • The boot banner prints device=, not mac=. That line is what gets pasted into a public issue, which is how a "local" identifier stops being local. Do not add the MAC back to it.
  • tools/board_registry.json is gitignored. board_registry.example.json ships instead, with placeholder ids, and ESP32_boardUtil.py --learn fills in the real one per machine. Reading a MAC off a chip with esptool is still correct when a board cannot introduce itself -- an empty flash, or after a merged-image install wiped NVS -- but it stays on that machine.
  • python tools/check_identifiers.py runs in CI on every pull request. It fails on MAC addresses in either separator style and on public IP addresses. It cannot recognise an SSID, a hostname or a child's name, so a clean run is not permission: it catches the shapes a machine can catch, and this rule covers the rest.

Storing is not publishing: a MAC held in NVS on the device, or in a gitignored file on the maintainer's laptop, is fine. Committing it is not.

Related, and still open: BleBeacon composes its advertised deviceId from the last two bytes of the BT MAC (esp_read_mac(mac, ESP_MAC_BT)). Those two bytes go out over the air. Moving it to Board::deviceId() would be strictly better, but it changes what the device transmits and what peers key their saved names on -- so it needs the maintainer's agreement first, like any other change to the advertisement.

No data collection - the rule that outranks the feature

Braino collects nothing about the player using it, and no change may alter that. It is not a setting that ships switched off; it is what the product is. Exactly four things leave the device: an NTP time query, one ip-api.com lookup to guess the timezone on first connect, the opt-in, non-connectable BLE beacon -- which carries the Nearby fields and pokes when those are switched on, and is still one flow rather than three -- and the daily update-availability check.

The fourth was agreed in 5.7.0 as a deliberate change to what the product promises, which is the only way this list may ever grow. Two properties are what made it acceptable and both are enforced rather than intended: the request says nothing whatsoever about this device -- no version, no board id, no query string, which is why the manifest lists every board and the comparison happens here -- and the address shown to the owner is compiled in, never read out of the response, which bounds a hostile answer to being wrong about a number rather than being able to send a child somewhere. check_privacy.py asserts both. It runs only when Wi-Fi is already configured, and it is not declinable; a device with no Wi-Fi never makes the request at all. See include/UpdateChannel.h.

That list is closed. Do not add analytics, usage counters, crash reporting, any other HTTP/UDP/DNS request, any dependency that phones home at runtime, or anything transmitted that carries a player's name, profile name, score, progress or typing. A fifth outbound flow needs the maintainer's agreement in an issue before the code exists - it is a change to what the product promises, not a feature to be reviewed on merit.

Storing is not collecting: scores and profiles live in this device's own NVS and never leave it. The full statement, including what to update if a transmitted-data change is ever agreed, is in CONTRIBUTING.md.

A privacy claim that has drifted from the hardware is worse than none at all, because it is believed. If you add or change anything that transmits, stores or shares data, the About radio page and the README Privacy section are part of that change. Not a follow-up.

About is also a system app, so it follows the orientation rule: lay out against tft.width() / tft.height(), never SCREEN_WIDTH / SCREEN_HEIGHT.

Responsiveness rule — know what a frame costs

The loop runs at a 20ms budget (FRAME_BUDGET_MS) and paces to a deadline: it sleeps only the remainder, so a slow frame is not punished twice. That budget is the whole allowance for touch, logic and drawing. Three things have each eaten most of it at some point in this codebase, and all three look harmless at the call site:

  1. NVS reads. Preferences is flash-backed — every getter is a hash lookup, not a variable read. screenSaverSeconds() ran once per loop iteration and gameVisible() up to ~180 times per launcher repaint. Anything read more than once per screen change gets a write-through RAM mirror in Board: the setter updates the mirror and NVS together, so a stale read is not possible. Theme, layout, brightness, idle timeout, active profile, game visibility, Wi-Fi credentials and the NTP hot-path settings all work this way now. Add to that list rather than reaching for prefs_ in a hot path.
  2. Blocking delay() inside a getter. Battery sensing slept 10ms per call and Ui::drawTopBar() calls two battery getters, so every top bar cost ~20ms — a whole frame — before anything was drawn. No delay() in anything a render path can reach. Sample on a cadence and cache.
  3. Rebuilding content that did not change. System Info reassembled every row on every frame while scrolling. Scrolling changes an offset, not content. Gate rebuilds behind a stale flag.
  4. Sampling a sensor from a getter. The battery gauge cached for 2s, which made the conversion rare without making it predictable: whichever frame found the cache expired paid for it, and that frame was almost always a top bar being drawn. A cache moves the cost off most frames, not off the render path. Sampling belongs on a task -- Board::sampleBattery() and Watchdog's monitor are the two examples -- publishing a snapshot the loop only reads.

Before claiming a screen is fast: System Info's Memory tab shows loop load, worst work, worst frame and NVS usage, and the watchdog logs a stall past STALL_WARN_MS. A worst frame above ~40ms is a bug, not a heavy screen.

Related: full-screen repaints are ~150KB over SPI and ~30ms of visible blanking, which is why Game has two levels of invalidation. Guard static chrome behind needsFullRender() and repaint only what moved.

A screen transition is always a full repaint. Every screen is a static instance reused for the life of the device, so it arrives carrying the dirty flags its last visit left behind -- and the last thing that visit did was clearDirty(). launch(), goHome() and relaunchActiveGame() therefore all call requestRender(); a screen's begin() must never be relied on for it. Getting this wrong does not look like a lost optimisation, it looks like a screen with no top bar and the previous one showing through, because renderStatic() is where both Ui::clear() and the chrome live. Partial repaint is an optimisation within a screen's lifetime. Entering one is not the place to be greedy -- a stable picture is worth more than the frame it costs.

And ask whether the screen needs to repaint at all. Three things change the header on their own schedule rather than the screen's -- the clock, the battery badge and the notification banner -- and the runtime used to answer each with requestRender(), wiping all 240 rows to change something in the top 30. The battery made that constant: one percent is about 2mV on the LiPo plateau, under two ADC counts, so the reading crossed a boundary every couple of seconds and turning the BLE beacon on -- whose supply ripple widened the noise -- made the console visibly flash. Game::renderChrome() repaints the strip alone, an eighth of the panel and inside the frame budget where a full repaint is 150% of it, and PERCENT_DEADBAND stops the number twitching underneath it. A screen with its own header overrides renderChrome(); one that cannot repaint its chrome in isolation returns false and gets the old behaviour. If you add something to the top bar, route its invalidation through requestChromeRender(), not requestRender().

Memory rule — there is no garbage collector

C++ gives you no GC, and FreeRTOS gives you a heap that can never be compacted. So the thing that kills this device is not the leak you are picturing. There is not one new, delete, malloc or free in this firmware — every screen is a static instance and everything else is stack or a fixed member. Keep it that way, and the classic leak is impossible by construction.

What actually kills it is fragmentation: many small allocations of differing sizes, made and freed over and over, chop the free space into pieces too small to satisfy a later request. Free heap looks fine right up to the allocation that fails, hours in. The only visible symptom beforehand is the fragmentation percentage, which is why System Info shows it.

The offender is almost always Arduino String. Every concatenation allocates, every assignment may reallocate, and a String member that is rewritten each frame is a long-lived block being freed and re-made 27 times a second.

Rules, in the order they bite:

  1. No raw owning allocation. No new/delete, no malloc/free, no owning raw pointers. If something genuinely must be dynamic, justify it in a comment and give it an owner with a destructor.
  2. No String in anything that runs per frame. Build text with snprintf into a stack buffer, or store it in a fixed char[]. RowList (src/ui/) is the worked example: it was 48 rows × 2 Strings rebuilt every frame — about 96 long-lived allocations churning at frame rate — and is now flat char buffers that allocate nothing, ever.
  3. Don't rebuild content on every frame. Rebuild when the data changed and keep a stale flag. Scrolling changes an offset, not the content.
  4. A String member on a screen is a smell. A few exist for genuinely user-entered text (ProfileApp::draft_, WifiApp::password_) — that is the bar. Anything derived from state belongs in a fixed buffer.
  5. Give back what you borrowed, in end(). Every screen transition goes through BrainoApp::leaveActiveGame(), which compares free heap against the value captured before that screen's begin() and logs [heap] '<screen>' left N bytes short when a screen does not hand it back. Watch the serial log after adding a screen.
  6. Prefer fixed-size members over growth. A statically sized array that is occasionally half empty is cheaper and safer here than anything that grows. Trading a few hundred bytes of static RAM for zero heap traffic is nearly always the right call on this device — RowList cost 864 bytes of RAM and saved 5.5 KB of flash.
  7. Check the numbers before you claim it is fine. pio run reports RAM and flash; System Info's Memory tab reports free heap, minimum free heap, largest allocatable block, fragmentation and NVS pressure. Read them.

ESP32 firmware (Arduino / PlatformIO, C++17) for a handheld educational console for young players. 40 games, all baked into flash. Target hardware is the E32R28T-1 / ESP32-32E (2.8-inch 240×320 resistive-touch board): ILI9341 320×240 TFT + XPT2046 resistive touch + onboard single-cell Li-ion/LiPo charging circuitry. Wi-Fi is used for NTP only — no accounts, no telemetry, no SD card required.

Build

pio run                  # build (env:app, the default)
pio run -t upload        # build + flash at 460800 baud
pio device monitor       # serial, 115200 baud

An environment is either the product or a bench probe, and it says which

platformio.ini declares fifteen environments; five of them are Braino!. Each one states which it is, once, beside itself:

custom_env_kind = product      ; or: diagnostic

tools/envs.py reads that and is the only place any workflow, checker or packer learns which environments to build. Nothing may write a list of environments into a workflow again. Three files each kept their own list -- ci.yml, pages.yml and the size-table loop -- all three named eighteen environments, and none of them named audiodiag, diag32p or audiodiag_e32r32p, which had been in this file for months. That is exactly the failure pages.yml warned about in its own comment while committing it: an environment nobody builds is one that is already broken and has not been told yet. A hand-kept list can be checked for typos and cannot be checked for completeness, so it has to be derived.

What follows from the classification:

  • CI builds the five product environments on a push to main or dev, and on a pull request builds app plus whatever the diff reaches.
  • Each environment builds in its own job, in parallel. ci.yml is three jobs: plan runs every repository check, asks envs.py what to build and installs all the toolchains once into the one cache it alone saves; build is a matrix with one runner per environment and fail-fast: false, each named after its board (build E32R28T-1 (app) -- envs.py --matrix reads BOARD_NAME from the board section, so a red job says which board broke and the environment beside it says how to reproduce it), each restoring that toolchain cache read-only plus its own per-environment object cache; verify waits for both and fails if either did. One runner building seven boards in turn was 14-15 minutes on a push to dev. verify is the required status check in the branch rulesets -- never rename it, and never make the matrix jobs required instead: their names carry the environment, so the set changes with the diff, and a ruleset can only wait for names it knows. verify runs if: always() so a docs-only pull request, where build is skipped, still reports.
  • A diagnostic is built only when its own source or platformio.ini moves -- tools/envs.py --for-changes derives that from each one's build_src_filter, so touching src/battery_diag.cpp builds the four batdiag environments and nothing else. A board header does not trigger one: the product environment for that board already compiles the same header, so building the probe again is a slower way of learning the same thing.
  • The Pages workflow builds product environments only. It offers no probe for download, so it has no business compiling one.
  • A release publishes product environments only. Every tag used to attach fourteen probe images nobody downloads; whoever needs one has the toolchain open and runs pio run -e batdiag -t upload, which gives them the current build rather than one a tag froze.
  • A new environment with no custom_env_kind fails the checks. An unclassified environment cannot be a default in either direction without the wrong answer being silent.

Ten diagnostic environments exist for hardware triage. Build one by name when you need it:

  • pio run -e bringup — full tree with -D CYD_BRINGUP_ONLY; main.cpp compiles a display/touch/SD check instead of the app.
  • pio run -e wifidiag — builds src/wifi_diag.cpp alone (build_src_filter = +<wifi_diag.cpp>), so no TFT/touch/game code can interfere with the radio test.
  • pio run -e batdiag — builds src/battery_diag.cpp alone, an eight-page battery bring-up and calibration tool. It exists because the questions the battery poses cannot be answered from inside the app: the frame budget, the watchdog and the 2s telemetry cache are all correct product decisions that get in the way of watching an ADC for an hour. BOOT cycles pages; CSV (ms,raw,adc_mv,cell_mv,pct,state) streams to serial for capturing a full discharge. It reads the divider ratio and the ADC fault ceiling from the same board profile the product does. It still carries its own charge-inference constants, which the product no longer has -- 5.10.0 removed the charging display -- so they are a bench aid for watching a charge, not a mirror of the firmware.
  • pio run -e s3diag -- builds src/s3_diag.cpp alone, a bring-up probe for the Freenove FNK0104B. It skips the touch calibration wizard on purpose: env:bringup runs that on a board with no stored calibration, which on a capacitive panel the XPT2046 code cannot read strands the display check behind a dead crosshair.
  • pio run -e diag4 -- builds src/diag4.cpp alone, a eight-page bring-up probe for the 4-inch ST7796 board. It exists because every fact that board needs stated in a profile is still a guess, and the three ways of being wrong -- wrong SPI bus, wrong driver, wrong backlight pin -- all produce the same dark screen with a healthy serial log. Page ID reads the controller's ID register back over MISO, which separates them without anything being visible; page BL sweeps candidate backlight pins in both polarities. It defines neither TFT_BL nor GUME_BOARD_HEADER, deliberately -- TFT_eSPI owning the backlight would defeat the sweep, and a probe must not depend on the board profile it exists to produce. Both are boardless envs, listed in check_boards.py's BOARDLESS_ENVS, because a [board_*] section is a claim of support and neither board is supported yet.

Every build is stamped, and nothing about the stamp is a -D

tools/build_stamp.py is a pre-build script wired in from [esp32_common], so every environment gets it. It writes the branch and the abbreviated commit into a generated GumeBuildStamp.h in the build directory, src/BuildStamp.cpp is the only file that includes it, and the firmware reads the values through BuildStamp:: -- never the macros directly, outside include/BuildStamp.h. About's last page, System Info's Device tab and the [boot] build= serial line all read the same three accessors, so the answer to "which firmware is on this board?" is one fact with three viewers.

BRAINO_VERSION cannot answer that question: it is identical across every flash of a release, which is exactly the case where you need to know.

Nothing whose value changes per build may become a build flag. PlatformIO folds the flags into its build signature, and env.Append(CPPDEFINES=...) in a pre-script reaches the Arduino core and NimBLE as well as src/. The branch and the commit were flags until 5.9.x, and the cost was measured rather than assumed:

pio run -e app, nothing changed            66 s,    1 object
pio run -e app, branch name different     333 s,  336 objects

Every object, to change a string that one translation unit reads -- and since a commit hash changes on every commit, that was the cost of committing. It also meant CI could never cache build output at all, because every CI run is a new commit. The generated header fixes both: the include path is a flag and never moves, the header's contents are not a flag and move freely, and only the file that includes it is rebuilt.

The same reasoning is why the diagnostic environments carry -D CYD_BRINGUP_ONLY and friends in build_src_flags rather than build_flags. Those macros are read only under src/, but as global flags they changed NimBLE's compile command too -- so env:bringup measured 306 s, identical to a full app build, to test one #ifdef in main.cpp. With them src-scoped and build_cache_dir on, it reuses the objects the app build already made.

The build time stays out of all of this: __DATE__ and __TIME__ come from the compiler for free, and the script deletes BuildStamp.cpp.o so they are always current. The consequence to know is that the stamp is the build machine's local clock in C's format, not UTC and not ISO -- it identifies a build, it is not a timestamp to compute with.

On GitHub Actions the checkout is a detached HEAD, so the script prefers GITHUB_HEAD_REF / GITHUB_REF_NAME over git rev-parse --abbrev-ref, which would otherwise say "HEAD". A tree with no .git at all -- a source tarball -- is a supported way to build, and stamps "unknown" rather than inventing something plausible.

Build gotchas

  • BLE pulls in NimBLE, not Bluedroid. h2zero/NimBLE-Arduino costs ~192 KB of flash for host plus controller; the core's Bluedroid stack costs several times that and this partition cannot absorb it.
  • NimBLE's log level is set on its own, to warnings. Left unset, NimBLE-Arduino copies CORE_DEBUG_LEVEL (NimBLELog.h), so the core's INFO made it print New advertiser: <MAC> for every device a Nearby scan heard -- 3.6 KB/s on the bench, 99.9% of all serial output, strangers' Bluetooth addresses in logs that get pasted into issues, and lines interleaved into the middle of [boot] and [ident] replies. CONFIG_NIMBLE_CPP_LOG_LEVEL=2 in [common] fixes it without touching the core's own level. It did not change the frame rate -- NimBLE prints from its own task -- so do not expect it to explain a slow worst=.
  • lib_ldf_mode = deep+ is required on env:app — transitive library headers do not resolve without it.
  • TFT_eSPI is configured entirely through -D flags in platformio.ini (USER_SETUP_LOADED=1, pins, USE_HSPI_PORT, fonts, SPI speeds). There is no User_Setup.h — editing one would do nothing.
  • One board = one [board_*] section plus one profile header. platformio.ini splits into [common] (true of every board), [esp32_common] (the MCU), and a [board_*] section per board holding only the TFT_eSPI macros, BOARD_NAME and GUME_BOARD_HEADER. An environment composes ${common.build_flags} with exactly one ${board_*.build_flags}. Put a board-specific -D in [common] and it becomes a claim about every board — check_boards.py fails on that. The panel is described twice, to TFT_eSPI and to us, and BoardConfig.h static_asserts TFT_WIDTH, TFT_HEIGHT and TFT_BL against the profile so the two cannot disagree past the compiler.
  • Partition is huge_app.csv (3 MB app). Flash is the scarce resource; artwork and data tables dominate.
  • CYD_SCREEN_ROTATION=3 is landscape with the USB edge at the bottom. Board::pollTouch() compensates for every rotation, so don't hand-correct coordinates in game code.

Working alongside other agents

Assume you are not the only one editing this tree. Several agents (Claude or otherwise) and the author may be working concurrently, and files can change underneath you mid-task.

Start by reading git status. Uncommitted work that isn't yours is normal here. If a file you need is already modified, someone is probably mid-change in it — re-read it immediately before editing, and don't assume your earlier read is still accurate.

Branch per requirement — in your own worktree

When a new requirement arrives, don't start editing the shared tree. Take an isolated copy:

git worktree add ../GUme-<slug> -b feat/<slug> dev

Then work entirely in ../GUme-<slug>. Branch names: feat/<game-id> for a new game, fix/<area> for a repair, docs/<topic> otherwise.

Use a worktree, not a bare git switch. Switching branches inside a tree that holds another agent's uncommitted changes either drags their work onto your branch or refuses outright — and if it succeeds, it silently strands their edits somewhere they don't expect. Right now this tree has substantial uncommitted work in it from at least one other agent, so treat the main checkout as occupied. (In Claude Code, worktree isolation can also be requested when spawning the work.)

A separate worktree also gives you your own .pio/ build directory, which removes the concurrent-build race described below.

Every feature and every fix starts from dev. Not main, not a release branch, not whatever the last worktree happened to be sitting on. The only exceptions are a change the maintainer has explicitly asked to be based elsewhere, and a hotfix onto a release branch that has already been agreed -- in both cases said out loud, in the request, before the work starts. If nobody said otherwise, the answer is dev.

This rule is not yours to overrule, and in particular it is not overruled by dev looking wrong. It has already failed once exactly that way: an agent saw dev sitting 55 commits behind main, concluded it was stale and therefore the wrong base, branched from main instead, and wrote a paragraph justifying it. The refs were local and had never been fetched. One git fetch origin showed dev was five commits ahead with work main did not have -- the reasoning was confident, articulate and built entirely on stale data. So:

  • git fetch origin before you form any opinion about a branch. A local ref is a memory of the remote, not the remote. Compare with git rev-list --left-right --count origin/dev...origin/main.
  • If dev still looks like the wrong base after fetching, stop and ask. Say what you measured and why it looks wrong. Do not decide it yourself, and do not proceed while explaining the decision -- an explanation is not an approval.
  • Basing on main and rebasing onto dev later is not a shortcut, it is extra work with a hazard in it. The release commits in main's history come along for the ride, so the feature branch quietly carries the version bump that drops -SNAPSHOT into dev, which is meant to keep it. Recovering from that means replaying your own commits with --onto, and knowing you had to is not something the rebase tells you.

Keep branches short-lived and rebase onto dev often -- Keep branches short-lived and rebase onto dev often — a branch that sits for days turns into exactly the merge this is meant to avoid. Build before you merge, and don't merge or push unless the user asks. main and dev are protected on GitHub: no force-push, no deletion, and every merge arrives through a pull request with the verify CI job green, so a direct git push origin main is rejected by the server rather than by convention. Releases go dev -> main after hardware testing. The full rules are in CONTRIBUTING.md.

Branching moves the risk rather than removing it. More parallel branches means more merges, and the failure below is a merge-time failure — so it gets more important, not less.

The one that bites silently

APP_REGISTRY[] (src/engine/AppRegistry.cpp) is the launcher spine. Each playable game's own AppMetadata now carries its launcher index, icon and default visibility, while the registry binds that metadata to a concrete GameInstances member. A bad merge can still duplicate or skip an index inside those metadata blocks, and the result still compiles; the symptom is a tile launching the wrong game or appearing in the wrong place.

After any merge, rebase, or conflict resolution touching AppRegistry.cpp or a game's local AppMetadata, re-run python tools/check_catalog.py before doing anything else.

Serialise on the spine, parallelise on the leaves

  • Leaves — safe in parallel: a game's own src/games/*.{h,cpp} pair. One agent per game is fine.
  • Spine — expect collisions: src/main.cpp, src/engine/AppRegistry.*, src/hal/Board.*, src/ui/Ui.*. Adding a single game still touches the registry and PLAYABLE_APP_COUNT, even though title/icon/index/visibility metadata now lives with the game. Make spine edits tight and land them quickly rather than holding them open across a long task.

The board is a singleton

There is one physical device and one serial port, and both are exclusive:

  • pio device monitor holds the COM port; an upload from another agent will fail while it's open. Release it when you're not actively reading.
  • Flashing destroys shared physical state. NVS holds touch calibration, profiles and scores that another agent may be mid-test against, and factoryReset() wipes all of it. Never flash or factory-reset on a shared board without saying so first.
  • One .pio/ build directory per working tree. Two concurrent pio run invocations in the same tree race on it — use a separate git worktree if you need to build in parallel.

Take the lock before you build or flash

Worktrees isolate source and .pio/, but not the board, the serial port, or the toolchain's shared package cache. Those need an explicit lock, and it has to live somewhere every worktree can see — so it goes in the shared git common directory, not in .pio/:

$lock = Join-Path (git rev-parse --git-common-dir) "gume-board.lock"

That path resolves to the same file from every worktree, and is never committed.

Acquire before any pio run, pio run -t upload, or pio device monitor — write your PID, the action, the worktree and a timestamp, so the next agent can tell whether you're alive:

"$PID|flash|$(Get-Location)|$(Get-Date -Format o)" | Set-Content $lock -Encoding utf8

The lock is per agent, not per board. Two agents must never flash the bench at the same time -- that is what it is for. But the one agent holding it may flash several boards at once, and should: each board is its own USB device on its own port. python tools/ESP32_boardUtil.py --flash does exactly that under one hold of the lock -- it builds each distinct environment once, all at the same time, then uploads to every port in parallel with -t nobuild, with one log per build and per port in .pio/. When testing, add --board <BOARD_NAME> and flash only the board the change is for; the whole bench is for when the owner asks for it, since a new commit makes every environment a full rebuild. Parallel builds were measured, not assumed: after a new commit, four environments took 210 s one after another and 97 s side by side, because each rebuild is mostly single-core dependency scanning and linking. Do not hand-roll a second parallel flasher; extend that one.

Release it in all cases when the build, flash or monitor session ends — including on failure. Remove-Item $lock.

If the lock is already held

Do not just delete it, and do not build anyway. Work out whether it is live or stale:

  1. Read the file and take the PID from the first field.
  2. Check whether that process still exists:
    Get-Process -Id <pid> -ErrorAction SilentlyContinue
  3. Cross-check for any build or flash actually in flight, in case the PID was recycled:
    Get-Process | Where-Object { $_.Name -match 'platformio|pio|esptool|python' }

If a matching process is running, wait — poll every ~10 s rather than busy-looping, and say what you're waiting on. Builds take a couple of minutes; a flash plus verify is under a minute. Keep waiting while the process lives.

If no such process exists, the lock is stale — delete it and continue. Stale locks are normal here: an agent that was interrupted, crashed, or had its command cancelled mid-build never got to release it. A leftover file must not be allowed to block every future agent, so clearing it is the correct action, not a workaround.

Say so plainly when you remove one — note the PID and timestamp you found — so it's visible if a build really was killed halfway and left .pio/ in a bad state. A lock older than ~15 minutes with no live process is stale beyond doubt.

The same reasoning applies to any lock PlatformIO itself leaves in ~/.platformio after an interrupted dependency install: confirm nothing is running, then clear it.

Shared budgets

Flash is global and nearly the binding constraint (2,581,433 / 3,145,728 bytes, 82.1%; NimBLE plus the BT controller account for ~192 KB of that). RAM sits at 88,036 / 327,680 (26.9%) -- higher than it was, deliberately: RowList traded 864 bytes of static RAM for zero heap traffic and storage diagnostics keep their profile-move buffers static. On this device that is a good trade every time. Two agents can each add artwork that fits locally and together overflow it. Read the size line from pio run and report it when you add data tables or images.

Modularity rule

If a file is becoming large (as a rule of thumb, src/main.cpp > ~400 lines of active logic in a single function, or any .cpp > ~600 lines total), refactor it into a more modular form first before making the requested change. Split into helper files, break large functions into smaller ones, or extract a new class — whatever fits the existing architecture. The refactor must not break existing functionality (build must still succeed and behaviour must be unchanged), and it must land as its own commit before the feature change that prompted it.

This keeps diffs reviewable, conflicts locatable, and prevents any single file from becoming a merge hazard.

Git hygiene

  • Stage explicit paths. git add -A will sweep up another agent's half-finished work.
  • Commit narrowly — one game or one concern per commit — so conflicts stay resolvable.
  • Don't rebase or force-push shared branches, and don't revert changes you can't attribute; an unfamiliar edit is more likely someone's in-flight work than a mistake.
  • Don't reformat, re-order includes, or opportunistically refactor files you aren't otherwise changing. Cosmetic churn in a spine file turns someone else's small diff into a merge conflict.
  • main and dev are protected branches. Open a pull request against dev; never try to force-push or delete either, and do not use an admin bypass to skip a failing check.
  • src/games/CountryDataTable.cpp is generated. Regenerating it rewrites the whole file — announce it rather than folding it into an unrelated change.

Architecture

setup()/loop() in src/main.cpp delegate to a BrainoApp singleton defined in src/engine/AppRuntime.*, which owns every screen as a static instance and implements GameHost.

Runtime views are now only Game (including Launcher, Profiles, Settings and ordinary games), ScreenSaver (self-playing Pong that mirrors rally colour onto the case LED), Asleep (backlight off, panel in low-power state) and Locked (the hold-to-unlock guard between either of those and the screen underneath). Boot opens the Profiles app first; after a profile is chosen, goHome() activates LauncherApp through the same begin/update/render lifecycle as the rest of the screens.

The idle path is driven by Board::idleAction(): SaverThenSleep runs the saver and then blanks after sleepSeconds(), SleepOnly blanks straight away at screenSaverSeconds(), SaverOnly never blanks. View::Asleep polls at SLEEP_POLL_MS (100ms) rather than the 20ms frame budget — there is nothing to draw, and holding 50Hz behind a dark screen defeats the point. It is panel sleep, not esp_deep_sleep: the CPU must stay up to poll touch, since no wake source is wired. Board::displayWake() blocks ~120ms for the panel, so its call site sits inside a Watchdog::Pause guard.

View::Locked sits between both idle views and the screen underneath, and is gated on Board::wakeLockEnabled() (default on, RAM-mirrored, global like every device setting). It is an accidental-touch guard, not access control: it is disjoint from the admin PIN, neither granting nor revoking admin, and resumeUnderlyingScreen() -- the single tail shared by exitScreenSaver(), wakeFromSleep() and the unlock -- returns to exactly the screen and orientation that were up before. It used to return the profile too; going idle now ends an admin session, which is the entry paths' doing rather than this one's -- see the invariant below. Three things about it are load-bearing:

  • The press that got you here is swallowed. enterLock() sets swallowTouch_, so a press held through a bag can never complete the gesture however long it lasts.
  • Text on this screen is measured against the live panel, never trusted. Two different things chop a line and they look different on the device: Ui::fitted() truncates and ends in a ., while TFT_eSPI drops characters outright once x reaches the viewport's right edge (drawChar: if (xd >= _vpW) return) -- no mark, cut mid-word. tools/gen_screens.py cannot reproduce either: PIL has its own font metrics and clips nothing, so a mock-up showing a sentence whole is not evidence that the panel does. The footer therefore picks the longest wording that measures whole and drawSentence() wraps and clamps it, and renderLock() resets the viewport because this screen owns the panel and must not inherit anybody's clip.
  • The hold tolerates dropouts. Resistive contact falls below TOUCH_PRESSURE_THRESHOLD mid-press as a matter of course, so gaps up to LOCK_CONTACT_GRACE_MS (150ms) do not restart the timer.
  • The header is fixed, and the vertical stack is measured against 240px. The wordmark and battery badge answer what a person finding a locked device wants to know without touching it. Nothing drifts: the saver moves its wordmark because it is up for hours, this screen for LOCK_TIMEOUT_MS, so the header is painted once inside the lockFullPaint_ branch and the progress bar stays the only thing repainted per frame. The whole stack hangs off lockButtonRect(), whose offset below centre went from +12 to +25 to clear the new hairline; landscape is the tight case and every gap is stated in the comment there. The battery badge is variable width, so it is placed off Ui::batteryBadgeWidth() rather than a constant -- same rule as the launcher header. It belongs top right, level with the middle of the mark, and it spent a release in the bottom corner instead on the reasoning that a status badge should not compete with the brand. The measurement disagrees: the footer is drawn centred across the full width and lockFooterText() deliberately picks the widest wording that measures whole, so a badge at the right-hand end of that row is in the footer's way. Up beside the mark there is nothing to hit -- the badge is 50px wide and centred, and 240px portrait is the tight case and still clears it by about 39px. It is centred on the mark's own height rather than a typed-in y, so it cannot drift if the logo size changes.
  • Locked is excluded from the idle-timeout block alongside ScreenSaver and Asleep; it runs its own LOCK_TIMEOUT_MS and hands back to sleep (or to the saver under SaverOnly). Leaving it in that block re-arms the saver timer against an already-expired lastActivityMs_.

BrainoApp::lockAndSleepNow() is the deliberate way in -- the Lock button -- and it is the same guard, not a second one: it sleeps through the ordinary enterSleep(), leaves activeGame_ alone and comes back through resumeUnderlyingScreen(). Two things go with it:

  • lockOnWake_ overrides the setting, one time. Both wake paths gate on wakeLockEnabled() || lockOnWake_, so pressing Lock produces the lock screen even for an owner who has switched Hold to unlock off -- that press is the request. resumeUnderlyingScreen() clears it, because that is already the one place that decides the lock is over.
  • The Lock tap is consumed by the runtime, above the active screen, beside the Home and gear routing and for the same reason: a tap delivered to the screen as well would press whatever sat under the padlock, and the user would find it done when they unlocked. Its slot comes from LauncherLayout::topBarLockRect() (top bar) or LauncherLayout::lockRect() (the launcher, which draws no top bar), and both the glyph and the hit test read those same helpers.
  • The top bar has no spare pixels, and Lock was paid for. Home narrowed from 42px to 32px and the title's start moved from 48 to 62, costing the title 14px. The right-hand cluster is measured, not padded, and cannot give; check the longest screen title ("Finger Counting") before spending any more.
  • The BOOT key is Home, and it is a shortcut rather than a route. It is consumed in the runtime above the active screen's update(), beside the Home, gear and Lock routing and for the same reason. Three deliberate holes in it: the launcher does not consume it (you are already home, and taking the frame would drop a simultaneous touch), the lock screen ignores it entirely (a key pressed through the side of a bag is the accident that screen exists to catch), and View::Asleep and the saver treat it exactly as a touch. It counts as activity, or the saver would arrive a moment after you pressed Home. Nothing may become reachable only through it -- BOARD.hasBootButton() can be false and the console has to remain complete. It fires on the press edge, which is what a hold gesture would have to change first; see src/hal/BoardButton.cpp.
Layer Where Responsibility
Game / AppGame / GameHost src/engine/Game.h Screen lifecycle plus the split between ordinary app context and privileged system host
Board src/hal/Board.h / BoardAccess.h Hardware aggregate plus narrow display/touch/storage/power/network/feedback facades
Ui::Renderer src/ui/Renderer.h / TftRenderer.h Driver-free RGB565 drawing interface plus the firmware TFT adapter
Ui src/ui/Ui.h Stateless themed drawing helpers; owns the colour palette
GameCatalog src/engine/GameCatalog.h Derived compatibility view over playable-game metadata
AppRegistry src/engine/AppRegistry.h Single source of truth for launchable apps and instance bindings
Sound / BoardAudio src/hal/Sound.h / BoardAudioCues.cpp / BoardAudio.cpp / BoardAudioBackend.cpp The console's sound vocabulary, the synthesiser that generates every one of them a sample at a time, and the codec/I2S/DAC hardware under it
Watchdog src/hal/Watchdog.h Background supervisor: reboots a hung loop, logs stalls and heap, keeps a crash breadcrumb
BleBeacon src/hal/BleBeacon.h Opt-in non-connectable BLE presence beacon. Owns the one authoritative advertisement payload, and its inverse decode()
BleScan src/hal/BleScanner.h Passive observer for other Braino beacons. Radio only -- no opinion about scores
NearbyPlay src/engine/NearbyPlay.h Nearby play policy: peer scores, header notifications, the sharing switch, and the two-player session service

Invariants worth knowing before editing

  • The board is described in two files and nowhere else. include/boards/<id>.h holds the whole board -- pins, rotations, the battery divider, which peripherals exist; the [board_<id>] section in platformio.ini holds only what TFT_eSPI must be told at compile time. No file under src/ may name a GPIO number, a panel size or a divider ratio. If you need one, read it off BOARD; if BOARD does not have it, add the field to include/BoardProfile.h and fill it in for every existing board in the same commit. tools/check_boards.py enforces the completeness, and BoardConfig.h static_asserts the overlap with TFT_eSPI. See docs/PORTING.md.

  • Some boards cannot be supported, and that is a compile error, not a degradation. Braino needs a panel of at least GAME_CANVAS_WIDTHxGAME_CANVAS_HEIGHT in landscape, a touch controller, the backlight on a GPIO, and 4 MB of flash. BoardConfig.h static_asserts each with a message naming what is missing. Optional hardware -- SD slot, RGB LED, speaker, battery sense -- is PIN_NONE plus a has...() guard, and the firmware does without it. Do not blur the two: turning a requirement into a quiet degradation ships a board that flashes and then cannot be read or pressed.

  • A supported board must be flashable from the web installer. tools/gen_site.py derives the picker's board list from the [board_*] sections rather than a hand-kept list, and refuses to generate until a new board has a BOARD_DETAILS label, an offered environment and a CI build behind it. tools/check_boards.py reports the same gaps without a build. "Supported" means someone who owns the board can flash it from the page, not that it builds here.

  • SCREEN_WIDTH / SCREEN_HEIGHT are derived, not stated. They come out of BOARD.screenWidth() / screenHeight(), which rotate the panel's native size by the profile's landscape rotation. Do not reintroduce a literal 320 or 240 next to them; a board that states its size twice will eventually state it two different ways.

  • Every screen is a Game. Launcher, Settings, Wi-Fi, Profiles, Scores, System Info and About are all Game subclasses with the same begin/update/render/end lifecycle.

  • end() is called on every screen change via BrainoApp::leaveActiveGame(), before the next screen's begin(). Add new transitions through that funnel, not by assigning activeGame_ directly. Override end() for anything a screen holds that outlives a frame; nothing here runs off a task or timer, and the hook is what keeps that true.

  • Never sample the battery ADC more than once per frame. Board::readBatteryTelemetry() caches for 2s and everything else reads through it. Each accessor used to run its own blocking 10ms conversion, and a top bar calls two of them. See src/hal/CLAUDE.md.

  • Ordinary games should not receive the full board anymore. Use AppGame + AppContext for catalog games; that surface is limited to Ui::Renderer drawing, content, scoped persistence, feedback and basic navigation. The only screens still on GameHost& are Launcher, Settings, Wi-Fi, Profiles, Scores, About and System Info, and system screens must guard privileged actions with requireCapability().

  • Profile scoping is automatic and invisible to games. Board::scopedKey() is private; it prefixes p{N}_ and translates plain game keys into compact app-scoped leaves inside getScore / setScore / saveBestScore / worstScore / loadBlob / saveBlob. BoardStorage.cpp owns the schema-versioned migrator from the older key format; BoardStorageMaintenance.cpp owns NVS usage telemetry and profile deletion: removing a player clears that slot's pN_ keys, shifts later slots down with their own persisted data, and clears the old last slot. Just call the storage API with a plain key and per-profile behaviour comes for free. Guest (GUEST_INDEX == 5) silently drops all writes — that is what makes it a guest rather than a sixth player.

  • Device settings are global, not per-profile: theme, layout, brightness, sound on/off, volume, Wi-Fi credentials, NTP, NTP resync interval, timezone. Sound belongs on that list for a reason worth stating: the speaker belongs to whoever is in the room, and a console that came back loud because a different player picked it up is a poor thing to hand a child in a quiet house. Per-profile: scores, mastery blobs, game visibility.

  • The admin PIN gates every route to admin powers: switching to the admin profile, opening its Edit menu (rename plus its per-player game list), and the serial console's unlock (see Hardware notes). One profile is admin (Board::adminProfileIndex(), one uint16_t PIN beside it in NVS). Add another way to become admin and it needs the same gate. It is asked every time, including when already admin: being admin is not evidence about who is holding the device, which is the whole threat model. The console's unlock is the one that lasts beyond a single action -- a batch of commands, expiring two minutes after the last one -- because a script configuring a bench is one decision, not twenty; lock ends it and the tool always sends it.

  • Per-player game visibility and profile removal are admin-only; renaming is not. ProfileApp gates on board.isAdminProfile(board.activeProfile()) — the actor, not the profile being edited. Those two are different questions and conflating them is exactly how Remove ended up available to every player. The Games list stays readable by anyone on purpose: a player who can see a game is switched off is better served than one facing a launcher that is short for unexplained reasons.

  • Settings is readable by everyone and writable only by the admin. There is no lock screen on it. The enforcement is a single if (!isAdmin(board)) early return in SettingsApp::update(), sitting below tab switching so a non-admin can still page through and read. The greyed-out controls are a drawing decision and enforce nothing on their own: for a while every greyed row was still live and a player could toggle the lot. If you add a control, it is covered by that early return automatically — do not add a path above it.

  • Boot must not leave the admin profile active. BrainoApp::begin() drops to Guest if it finds admin selected. The picker's Done button goes home with whatever is already active, so a remembered admin selection is a PIN bypass, not a convenience. Do not "restore the last profile" here.

  • Going idle must not leave the admin profile active either. Same fact, arriving a different way: the console was put down, and who picked it up is not something the device knows -- which is the whole threat model the PIN is written against. endAdminSessionForIdle() is called by both idle entries, enterScreenSaver() and enterSleep(), so the saver, panel sleep, the Lock button and the lock screen's own timeout are all covered by one line each. Before it, an adult could open Settings, walk away, and whoever touched the panel next had every switch on it with no PIN asked; the lock screen does not close that, because it is an accidental-touch guard whose hold is deliberately not a secret. It drops on the way in rather than on the way out so that no exit can forget, and it sets adminEndedByIdle_ so that resumeUnderlyingScreen() starts the screen over instead of resuming it -- that part is not tidiness: Settings' change-PIN pad sits above that screen's own admin gate, being reachable only by an admin, so a resumed pad would let whoever came back set the PIN.

  • A PIN's digit count is not derivable from its value. 0000 and an empty field are both zero, so every PIN entry point tracks digits separately from the number, and only judges an entry once it is exactly four long. Both screens got this wrong first time and silently accepted a three-digit prefix.

  • Both PIN pads are laid out against the live tft.width()/height(). The first version hard-coded rows at y=220 and the action buttons at y=270 on a 240px-tall panel, so the bottom row, DEL and OK were all drawn off the screen and there was physically nothing to press. Anything added to either pad must still end above screenH.

  • Each playable game declares its own metadata once. AppMetadata owns id, title, screen title, subtitle, launcher label, blurb, score pointer, launcher icon, launcher index and default visibility. APP_REGISTRY only binds that metadata to the concrete static instance.

  • APP_REGISTRY holds the 40 playable games plus 7 launchable system apps. The launcher itself is not a tile in that table; it is LauncherApp, activated by goHome().

  • Metadata launcher indices must stay contiguous and index-aligned. check_catalog.py enforces this now, but the failure mode is still the same: a misalignment launches the wrong game from the right tile.

  • The launcher shows the profile name as plain text, not a button. The framed chip is what overlapped the status badges; the name itself is wanted. launcherProfileRect() is both where it draws and the touch target, so the two cannot drift — in landscape it sits after the byline, not across it.

  • The launcher status badges are packed to the pixel. Landscape runs from a hairline at lW-138 to the gear at lW-30, and the Lock badge sits at its left-hand end. The battery badge is variable width -- it carries its own percentage, so it grows with its digits, widest at 100 -- and in that widest state the row has only a few pixels spare. Everything on it is therefore laid out right-to-left off Ui::batteryBadgeWidth() and the measured width of the clock string, never a constant offset; the hairline has moved out twice to buy those pixels -- lW-110 to lW-116 for the battery percentage, then to lW-138 for the Lock badge -- and LauncherLayout::profileRect()'s right limit moved with it both times. Lock is a badge, not a control: it is drawn at 18px beside the battery and Wi-Fi glyphs rather than at the gear's 26px, because it belongs to that family and a gear-sized padlock read as the most important thing on the header. Portrait has room to extend the badge row instead -- with one measured exception: the mute control does not fit that row in portrait. At 240px the badges reach about x=155 and the padlock starts at lW-64, which leaves roughly 20px for an 18px glyph plus its gaps, so it goes on the profile-name row above, whose right-hand half is empty because the name is capped at 112px. It sits in the padlock's column (speakerRect() takes lockRect().x) rather than mid-row: at lW-96 it was beside nothing and above nothing, and it read off both portrait panels as an icon floating in an empty row. Anything new in that header needs the same treatment — measure, don't guess.

  • The BLE advertisement has exactly one description. BleBeacon::Advertisement is compiled into a raw AD buffer that is handed to the controller verbatim, and the System Info BLE tab reads that same buffer back. BleBeacon::decode() is the exact inverse and is what the scanner reads peers with -- never write a second parser. With Nearby play on the payload is exactly 31 bytes, so there is no room for another AD structure or a longer name -- which is why the poke had to displace a field rather than add one. See docs/BLE_BEACON_SPEC.md.

  • Nearby play is off by default and gated on the beacon. NearbyPlay::tick() re-derives that gate every frame rather than trusting an ordering contract with Settings, so turning the radio off takes the feature with it. What it shares is a game index and a best score, never a name or anything profile-scoped.

  • Two-player-over-the-air is a SERVICE, not a chess feature. NearbyPlay plus AppContext's nearby calls are stated in the terms every two-player game shares -- who is in the room, who offered whom a game, which of the two moves first, one numbered turn at a time, and either side stopping. Nothing in it knows what a move means, and the wire layer is named turn/session rather than chess for exactly that reason. Two questions are answered once, here, so the next game cannot answer them differently: who moves first is a coin toss inside nearbyInvite() -- the console that asks for a game must not also claim the first move -- and "I am stopping" is a reserved turn encoding the service owns, surfaced to games as NearbyTurn::ended. If a future game needs something this cannot say, widen it here rather than reaching past it into BleBeacon.

  • Peer labels are a display concern that travels one way. NearbySeat carries the owner's own name for a console beside the tag it advertises, and every notification goes through NearbyPlay's displayName(), so naming a peer changes what the whole device calls it rather than what one screen does. The direction is the invariant: names are read from local NVS towards the screen and there is no path back to the radio. BleBeacon does not read them and must never be given a reason to -- the advertisement is identical byte for byte whether every peer is named or none is.

  • Two consoles can play each other, and the moves ride the same beacon. Agreed with the maintainer before the code existed, which is the rule for a change to what the device transmits. It is not a new outbound flow: it is the existing opt-in beacon, gated on the same two switches, still non-connectable. What goes on air is a session number, a move number, two square numbers and an ack -- thirty-two bits, in the four bytes the score was using, because the payload is already exactly 31 bytes and a move had to displace something. No name, no profile, no label, no score travels with a move, and that is structural: AppContext's nearby surface is move-shaped, so a game cannot put arbitrary bytes on the air even if it wanted to. Every received move is checked for legality in the receiver's own position, which is what stops a hostile advertiser corrupting a board. It is a BROADCAST -- everyone in range hears the moves, only the two playing act on them -- and the docs must keep saying so. Ending a game rides the move field with from == to, which is never a legal move and so cannot be confused with one; it adds no bytes to a payload that has none to spare, and it is tested before the whose-turn check because a player gives up while they are waiting.

  • Ludo seats up to four consoles on the same turn, and the wire did not change. Agreed by the maintainer in conversation on 2026-09-10, explicitly without an issue -- a departure from the rule above, recorded here so it is not mistaken for precedent. It needed no new flag and no version bump: a console reads every other console's turn by tag (nearbyTurnFrom()), the host invites them one at a time, and Ludo::Net (in LudoRules.h) spells Ludo's meaning into the same from/to/ack six-and-seven-bit fields -- a seat and a token, or a presence, or the host's start word. The die never goes on the air: every console derives each roll from the table's seed and Ludo::Net::accept() refuses any move that does not fit it. Two things differ from two-player play and must stay stated: who moves first comes from the seed, not from nearbyInvite()'s coin toss, because a toss between two cannot seat four -- the deal is a shuffle nobody chooses; and a console replaces its turn only once every other console has acked it (LudoGame::canPublish()), which is what makes a bonus roll or the host playing a computer seat safe on a medium that drops things. The service gained two calls for it, neither of which transmits anything: NearbySeat::forThisGame (an invitation names its game; a lobby must not accept another game's) and nearbySelfId() (so every console orders the table alike). Still a BROADCAST: everyone in range hears every move. A console that goes quiet pauses the whole table, and once it is Gone the host may play on without it: Ludo::Net::takeoverFrom(seat) is a numbered ply in the from values 16..19 that nothing else uses, saying that seat is the host's computer seat from here on. Only the host's word counts, a guest whose own seat is taken goes to its lobby told why, and it is a new meaning for existing bits rather than a payload change -- which is the whole of why it needed no fresh agreement about the air.

  • Backgammon's dice do not go on the air either, and that is what made it fit. An earlier plan ruled nearby Backgammon out because the turn has no field for dice. It does not need one: as in Ludo, both consoles derive every roll from Bg::tableSeed(session, both tags), and a move is one checker -- from a point or BAR (24), to a point or OFF (25) -- which is the two-player turn exactly as Chess sends it. Every move received is played only if Bg::findMove() finds it legal with the dice the receiver computed, the forced-move rules included. A turn goes on the air on Done, one checker per ply, each once the other console has acked the last; a turn with no legal move sends nothing, because both consoles compute that it has none.

  • A console that goes quiet pauses the game, and the service says when. The second way a two-player game ends never arrives as a message -- a flat battery cannot send nearbyEnd() -- so NearbyPlay::peerSilentMs() reports the silence off the scanner's own lastSeenMs, and NearbyWatch (src/games/) turns it into Present / Quiet (PEER_QUIET_MS, 6s) / Gone (the scanner's 45s TTL) and one card: waiting for whom, for how long, Keep waiting or End game. Every nearby game hooks it at the same two places; none may decide "too long" for itself. Nothing transmits for it: it is derived from the absence of the beacon that is already there, which is why it needed no agreement about what goes on the air. See src/games/CLAUDE.md.

  • A game that persists needs a way to be abandoned. Chess writes its board to NVS after every move and on the way out, which is right -- children put the device down constantly and a game that evaporated is a game they stop starting. But it retires the oldest exit there was: before this, walking away ended a game nobody could finish, and now walking away brings it straight back. So End game is not a nicety bolted on beside persistence, it is the other half of it. The same applies to anything else here that learns to remember an unfinished state.

  • Local peer names never reach the radio, and that is structural. Board::peerName() / setPeerName() hold up to 8 labels of 10 characters in one NVS blob with a RAM mirror. BleBeacon does not read them and must never be given a reason to -- buildPayload() composes the advertised name from the family id and the hardware id, so the payload is identical byte for byte whether every peer is named or none is. A label reaching the air is a privacy defect, not a bug. They are global, not profile-scoped: saveBlob() is transparently profile-prefixed and Guest silently drops writes, so a guest naming a peer would watch it work and lose it. Setting one is admin-only, enforced in NearbyApp::update() rather than by withholding the chip -- a chip is a drawing decision and enforces nothing, which is how every greyed-out Settings row stayed live once already.

  • A poke rides the beacon and displaces the score; it is not a fourth outbound flow. There is no room for one: the sharing payload is exactly 31 bytes, so FLAG_POKE swaps the four score bytes for a two-byte target and a one-byte nonce for POKE_ADVERTISE_MS. Three consequences are load-bearing. Every field is gated on its own length in decode() -- version 2 tested game and score together, which a poke's shorter block answers wrongly for both, which is why PAYLOAD_VERSION is 3. The nonce is what makes it an event: the poke repeats for seconds because scan windows have gaps, and a receiver acts on a (device id, nonce) pair exactly once; it is never reset, or a second poke to the same peer would read as a repeat. It is a broadcast -- everyone in range hears who poked whom, only the target reacts -- and the docs must keep saying so rather than implying a private channel. The one identifier it carries is the target's own advertised id, so it adds an event to the radio, not a new kind of data.

  • A find is a poke that asks to be heard, and it spends a reserved flag bit rather than a byte or a version. FLAG_FIND (bit 4) makes the target ring Sound::Bell on a cadence, blink its LED and wake its panel, for ALERT_MS. It adds nothing to a payload that has nothing to add to. It does not bump PAYLOAD_VERSION, and must not: decode() rejects the whole manufacturer block on a version mismatch, so a bump makes consoles either side of it invisible to each other in Nearby -- worse than the problem. That is safe here only because no length and no existing field changed meaning, so an older reader sees a poke and blips. A future flag that moves a field does have to bump the version. It is still a broadcast: everyone in range hears who is looking for whom, only the target rings.

  • There are no audio files, and there must never be one. Every sound the console makes -- the cues in hal/Sound.h, the four Cinnamon pad notes, and the spoken "Let's play Braino!" at boot -- is generated by BoardAudio.cpp (the cue tables themselves live in BoardAudioCues.cpp) from a script of oscillator, noise and formant segments. No WAV, no PCM table, no sample bank, and nothing decoded at runtime. This is a flash rule before it is an aesthetic one: one second of 16-bit 16kHz mono is 32 KB, so the vocabulary as recordings would cost more than the whole game catalogue's artwork, on a budget already at 82.1%. As synthesis it is under a kilobyte. The spoken phrase is a phoneme table, not text-to-speech -- there is no dictionary and there is no second phrase; adding one means writing its phonemes out by hand, which is the intended cost.

  • Mute is gated in exactly one place, Board::playSound(). Every sound in the firmware goes through that door -- both beeps and the boot phrase included -- so a switch labelled Mute cannot leave something still audible. The RGB pulse is deliberately not gated: beepOk()/beepError() pulse before they call it, so muting takes the sound and leaves the light, which is the whole of the feedback on a codec-less board anyway. soundEnabled() and volume() are RAM-mirrored write-through settings because the first is on the path of every cue in every game.

    Exactly one thing may change the switch itself, and it puts it back. A find alert (see above) unmutes a muted console so that it can answer, and NearbyPlay::dismissAlert() restores it -- which is why every way an alert can end, a touch and the BOOT key and its own timeout, funnels through that one function. Note what this is not: nothing reaches past Board::playSound(), so the invariant still holds literally -- a muted console is silent, and this console is briefly not muted. It is the same shape as lockOnWake_: a one-time override of a device setting, cleared in the one place that already decides the episode is over. If you need a second such override, be sure it can say the same two things.

  • A screen makes a noise through playSound(Sound::...) and nothing else. Board::beep(freq, ms) is private on purpose. A shared vocabulary is the point -- Coin means the same thing in Whack-a-Mole as in Memory, and a game picking its own frequencies is exactly how that stops being true. Cinnamon's four pads are the one pitched exception and they are in the vocabulary for that reason. Adding a cue is adding a word to a language: do it when a game has something genuinely new to say, not when an existing cue is nearly right.

  • A cue is armed, never played. playSound() copies a script and returns in microseconds; a dedicated task, braino-audio, generates the samples. Never write a blocking i2s_write on a render path -- a 300ms note is fifteen frame budgets and Watchdog will log the stall. src/s3_diag.cpp does block, correctly, because a bring-up probe has no frame budget.

  • Audio generation is on a task because a frame is not a deadline it can meet. It used to run from tickAudio() in the loop, on the argument that the DMA holds 96ms against a 20ms budget. That holds for a typical frame and fails for the one that matters: a launcher page turn repaints the whole screen, which on the 4-inch panel outlasts the buffer, so the cue armed just before it was cut off mid-sound. A deeper buffer only moves the threshold -- the worst frame is bounded by nothing. The task runs above the loop on the same core so it preempts the repaint, generates under audioLock and blocks outside it, so playSound() never waits on the DMA. This is the same move the responsiveness rule already prescribes for the battery gauge and the watchdog: work whose deadline is not the frame's does not belong on the frame.

  • The loop is watchdogged. Watchdog::feed() is the first statement in BrainoApp::loop() and a frame over TIMEOUT_SECONDS = 12 reboots the device. Anything that blocks the loop task for longer on purpose — a calibration wizard, a network round trip — must sit inside a Watchdog::Pause guard, or it will look exactly like a hang. See src/hal/CLAUDE.md.

A theme is not done until every screen has been seen in it

Adding or changing a theme means running these two, in this order, and looking at what the second one writes:

python tools/check_contrast.py          # every pairing, against the WCAG floors
python tools/gen_screens.py --themes    # docs/theme-sheets/<theme>.png

The check is arithmetic and catches what arithmetic can: text that cannot be read on what it sits on. It cannot catch a glyph drawn in a colour the palette never chose, and that is what nine themes shipped with -- a Home button painted TFT_WHITE on Classic's white bar, so the button was simply not there; launcher tile labels fixed white over a fill the theme picks, unreadable on three of them; a battery badge in one of two greys chosen for Dark, invisible on Pocket's green; primary buttons in a hard-coded web blue on all nine. Every one of those looked perfect in the Dark mock-ups, which were the only mock-ups there were.

The sheets render a representative set of screens -- launcher, About, Settings, System Info, Scores, a game, a tracing canvas, the lock screen -- in each palette. Read them for three things:

  1. Is every glyph still there? A control that vanishes into its background is the failure this exists to catch, and it is invisible in a diff.
  2. Does anything look like it belongs to another theme? A colour that does not move when the palette does is a constant that should be a role. barText, the tile fills, radius and accent were each found that way.
  3. Is the ink on a coloured fill readable? Text over a themed fill takes Ui::onFill() / Ui::onFillSoft(), never a chosen black or white.

A palette that passes the checker and fails the sheets is normal. The checker is a floor; the sheets are the design.

Adding a game or an app — the whole checklist

The old version of this list had five items, all of them code. Everything that has since been shipped broken or stale was outside those five: the README game table, the screenshot gallery, docs/screens/, the changelog. A game that launches correctly and is invisible in every document describing the product is not finished.

Work through all four groups. Nothing here is optional for a screen that ships.

1. Code — miss one and it fails silently or won't link

  1. src/games/NewGame.{h,cpp} — subclass AppGame unless it is a privileged system screen.
  2. In the game's .cpp, declare one AppMetadata block. Blurbs render at font 1 across ~292 px, so keep them under ~46 chars. Put the launcher icon, launcher index and default visibility in that metadata block, not in the registry.
  3. If it records a score, declare one local AppScoreInfo and point the metadata at it.
  4. src/engine/AppRegistry.cpp — add a metadataCatalogApp(...AppMetadata(), instance) entry at the intended playable position.
  5. src/engine/AppRegistry.h — update PLAYABLE_APP_COUNT.

2. Docs — the part that actually gets forgotten

  1. README.md — a row in the right game table (what it is, what it builds, age), or a bullet under the system screens if it is an app.
  2. README.md — an <img> in the matching screenshot gallery.
  3. CHANGELOG.md — an entry under the unreleased heading.

3. Screens

  1. tools/gen_screens.py — a render function plus an entry in SCREENS or EXTRA_SCREENS. Take the geometry from the game's own Rect helpers so the mock-up matches the device rather than approximating it.
  2. Run python tools/gen_screens.py and look at the PNG. It is a generated image; nothing else will tell you it came out wrong.
  3. tools/gen_site.py — nothing, usually. The landing page shows six chosen stills (HERO_STILLS) rather than all of them, and its game list is names read from AppRegistry, so a new game appears on it without anybody touching the generator. It showed all ninety-one once, with three per game card, and that is why this step used to be a wiring job; the page is now a page rather than a contact sheet, and the README gallery is where a reader goes for every screen. Only if you want the new still to be one of the six: add it to HERO_STILLS and give it a SCREEN_CAPTIONS entry, and take one out — six is the number because more stops selling and starts listing.

4. Verify

  1. pio run — and put the new flash/RAM figures in README.md and CLAUDE.md. They are the two places that disagree.
  2. python tools/check_docs.py — must be clean.
  3. Flash it and actually play it. Take the board lock first.

What you do NOT hand-edit

These derive from AppRegistry and update themselves. Editing them by hand is how About fell six games behind in the first place:

  • the About app's game list, count and blurbs
  • the Settings → Games visibility list
  • the launcher tiles and paging
  • the GitHub Pages site — tools/gen_site.py reads the version from AppVersion.h, the game list and blurbs from AppRegistry, the build figures from README.md and the board name from platformio.ini. Edit site/index.template.html for wording and layout only; check_docs.py fails if a version number is typed into it. The game list and the six-still wall are generated too, so do not add an <img> or a game name to the template — see step 11 above

If it is a system app rather than a game

Everything above still applies, plus:

  • It must work in both orientations. Read tft.width() / tft.height() at render time, never SCREEN_WIDTH / SCREEN_HEIGHT. Settings and Wi-Fi currently violate this and are the reason the rule is stated so bluntly.
  • If it touches a radio, stores data, or changes what leaves the device, the About radios page and the README privacy section are part of the same change — and About must read the state, not restate it.

Rendering model

App-facing rendering uses Ui::Renderer: a driver-free RGB565 primitive interface implemented on hardware by Ui::TftRenderer over TFT_eSPI. HAL bring-up, calibration and BMP blitting may still use the raw panel driver, but games and shared UI helpers should not. There is still no framebuffer. A full 320×240 wipe pushes ~150 KB over SPI — roughly 30 ms of visible blanking — which is why Game carries two levels of invalidation:

  • markDirty() — content changed; repaint moving parts only.
  • markFullDirty() — layout changed; repaint background/chrome too.

A FULL REDRAW IS THE EXCEPTION, IN EVERY GAME. Earn it.

markFullDirty() clears the screen and repaints the chrome: ~150KB over SPI and roughly 30ms of visible blanking, which is one and a half frame budgets. Doing it to change a few pixels is not a lost optimisation, it is a visible flash in the player's hand, and on a screen that updates often it is the first thing anybody notices.

Before reaching for it, ask what actually changed on the panel:

  • Something appeared? Draw it. Nothing needs erasing, so nothing needs clearing: overdraw it and touch nothing else.
  • Something moved? Erase its own box and repaint the guide over that box. Derive the box from the geometry that drew the thing; never type in a rectangle. Drawing functions that paint exactly what is already there in the same colours are idempotent, so re-running one whole is visually a no-op outside the box you cleared, which is usually cheaper and always simpler than working out precisely which pieces overlapped.
  • Something animating on its own clock? Make it change colour rather than size. A marker that never grows never has to be erased, which is what keeps the incremental path available at all.
  • The scene genuinely changed (a new question, a new letter, a new screen, an end-of-game banner)? Then repaint fully. A stable picture is worth the frame it costs when the whole picture is new.

This is not theoretical. LetterTracer's direction arrow was first written to markFullDirty() whenever it moved, on the reasoning that a moving arrow changes the picture's shape and turns are rare. Turns are not rare: a three-letter joined word has about eleven, so tracing one word cleared the screen eleven times, and it was reported from the device as the screen flashing. The fix was fifteen lines (erase the arrow's own box, repaint the ghost and the dots over it) and the reasoning that produced the bug was a guess about frequency that was never checked.

The same rule holds for the top bar: route clock, battery and notification changes through requestChromeRender(), not requestRender().

  • render() should guard static chrome behind if (needsFullRender()) and draw dynamic parts unconditionally.

These three are protected; the public surface is needsRender(), clearDirty(), and requestRender() (which forces a full repaint, used when returning to a screen). First paint is always full.

A partial repaint's clear rectangle must be derived from what it has to avoid, never typed in. This is the most expensive mistake in this codebase to see, because the code reads correctly and the wrong pixels appear only after some other element redraws. TimeGame has now produced it twice. Its score header cleared two 140px-wide strips -- far wider than the text needed -- while the clock dial spans x=113..207, so every score change erased 37px off each shoulder of the clock and left the face as a narrow strip with square bites out of it. It happened on the first frame, on every board, from the day the screen was written; the 4-inch panel simply made it 48px a side. The same screen had already been caught clearing a prompt strip that took the bottom off two answer buttons.

So: write the geometry once, at file scope, and derive every clear rectangle from it with a few pixels of daylight -- HEADER_W = CLOCK_CX - CLOCK_OUTER - MARGIN - GAP rather than 140. Leave a static_assert where the derivation could collapse. Exact adjacency is not good enough on a scaled panel: positions scale by their axis and radii by the smaller of the two, and each rounds independently.

And note that tools/gen_screens.py cannot catch any of this. A mock-up draws elements in isolation, in the order the generator happens to use, with no clear rectangles at all -- so an ordering bug or an erase-over is invisible in it by construction. The Time mock-up also omitted the question label entirely, which is how a dial printed over that sentence survived every screenshot review. When a screen looks right in docs/screens/ and wrong on the panel, this is the first thing to suspect.

Clip scrolling content with tft.setViewport(x, y, w, h, false) and reset it after. Skipping rows that fall entirely outside the viewport is not enough — the row straddling the edge still draws in full and smears into the chrome above it, which is what System Info did into its own tab strip. vpDatum=false keeps drawing coordinates absolute, so nothing else in the draw loop changes.

Most games still repaint wholesale. Cinnamon is the reference for partial redraw — it was also a photosensitivity concern at full-flash rates, so prefer partial redraw for anything that updates rapidly.

Playable games are authored against a fixed 320×240 landscape canvas. Launcher and system/UI apps support portrait (LayoutMode::Vertical; the launcher uses 4 tiles/page vs 6 in landscape, and 9 -- a 3x3 grid -- in portrait on a panel whose short side is at least 320px, i.e. the 4-inch board. LauncherLayout::grid() is the one answer to "how many columns and rows", read by the tile rects, the page size and the tile colouring alike; in the 3x3 grid a subtitle too wide for its 88px goes onto two lines rather than being chopped).

System/UI apps (Settings, Wi-Fi, SystemInfo, Profiles, Scores, About, and any future app-style screens beyond the playable game catalog) must support both landscape and portrait orientations. They must read tft.width() / tft.height() at render time rather than the compile-time constants SCREEN_WIDTH / SCREEN_HEIGHT, and lay themselves out responsively. Use Ui::drawTab() + Ui::drawTabBaseline() for multi-section content; the tab strip width adapts by dividing tft.width() at render time.

Layout

include/BoardProfile.h    the contract every board fills in
include/BoardConfig.h     selects one profile; derives SCREEN_* from it
include/BuildStamp.h      branch/commit/build-time accessors
include/boards/           one header per supported board -- the ONLY place
                          in the tree that names a GPIO      include/AppVersion.h
src/main.cpp              bringup entrypoint + normal app setup/loop
src/BuildStamp.cpp        which build this is; recompiled every build
src/wifi_diag.cpp         standalone radio test (env:wifidiag only)
src/s3_diag.cpp           standalone ESP32-S3 bring-up probe (env:s3diag only)
src/diag4.cpp             standalone 4-inch ST7796 bring-up probe (env:diag4 only)
src/engine/               Game, LauncherApp, GameCatalog, AppRegistry, NearbyPlay,
                          AppRuntime, AppRuntimeLock, AppRuntimeNotify (the
                          header banner), AppRuntimeIdentity,
                          AppRuntimeConsole (+Settings, +Profiles),
                          ConsoleText,
                          ScoreCatalog, Progress,
                          RecentQuestions, ContentLoader
src/games/                one .h/.cpp pair per game + GameInstances.h +
                          LetterTracer (the finger-tracing engine Trace and
                          Cursive share: logic, Draw, Arrows, Words and a
                          Layout header), CursiveGlyphData (generated) +
                          Country/State, Maze and Trace data.
                          Settings is three .cpp against one header --
                          SettingsApp (tabs + routing), SettingsPanels
                          (the tab bodies), SettingsPin (the PIN pad).
                          Ludo is six .cpp against one header -- LudoGame
                          (flow, input), LudoBoard (the board), LudoPanel
                          (the side panel), LudoLobby (both lobbies),
                          LudoTable (play across consoles) and LudoSave --
                          over LudoRules (the rules, the computer player and
                          the table protocol, pure C++ with no Arduino).
                          Backgammon likewise: BackgammonGame (flow, input),
                          BackgammonDraw, BackgammonNet (the nearby game),
                          BackgammonSave, over BackgammonRules and
                          BackgammonAi (pure, host-tested).
                          Chess is six .cpp against two headers -- ChessGame
                          (flow, input), ChessDraw, ChessNet (the lobby and the
                          nearby game), ChessSave, over ChessRules (the rules)
                          and ChessAi (both computer levels), which are pure
                          C++ with no Arduino and are host-tested; Sea Battle is four: SeaBattleGame
                          (flow, input, the fleet), SeaBattleDraw,
                          SeaBattleNet, SeaBattleSave.
                          NearbyWatch is the pause every nearby game shares
                          when the other console goes quiet.
                          GoRules (the rules, scoring and the wire encoding)
                          and GoAi (both computer levels and the dead-stone
                          estimate) are pure and host-tested, like
                          Backgammon's.
src/hal/                  Board bring-up, BleBeacon (the radio) +
                          BleBeaconPayload (the one description of what goes
                          on air, and decode(), its exact inverse),
                          BleScanner, BoardAccess facades,
                          per-concern HAL units, BoardAudio (the synthesiser) +
                          BoardAudioBackend (codec, I2S, amp) + BoardAudioCues
                          (every cue and the spoken phrase),
                          Sound.h (the cue vocabulary), BoardButton (the BOOT
                          key), BoardUpdate (is a newer firmware available --
                          a notice, never an OTA), BoardStorage, storage
                          maintenance, TouchTypes,
                          Clock, Watchdog
src/ui/                   Renderer, TftRenderer, Ui, Keypad, LauncherIcons,
                          LauncherLayout, LogoMask (generated -- the product
                          mark, as a one-bit silhouette)
tools/                    gen_screens.py, gen_site.py, check_docs.py,
                          gen_logo_mask.py (the product mark, from
                          tools/braino-badge.svg -- writes a preview that MUST
                          be looked at),
                          gen_cursive_glyphs.py (cursive letterforms, from a
                          GPLv3 dotted teaching font -- writes a preview sheet
                          that MUST be looked at),
                          check_boards.py, check_catalog.py,
                          check_contrast.py (every theme's colours against
                          the WCAG floors),
                          check_frame_rules.py, check_identifiers.py (no MAC
                          or public IP may reach this repo -- see the rule
                          above),
                          check_licenses.py (every file states its licence,
                          its holder and what reuse requires; --fix writes
                          the missing ones, and every generator imports its
                          header_for()), build_stamp.py,
                          envs.py (which environments are the product and
                          which are bench probes -- the only list),
                          pack_release.py, split_render.py,
                          fetch_release_firmware.py (past releases, for the
                          installer's version picker),
                          configure_boards.py + bench_config.example.json
                          (set every board up from one config over the
                          serial console; the real config is gitignored),
                          ESP32_boardUtil.py + board_registry.example.json
                          (which board is on which port, keyed by the
                          firmware's own Board::deviceId(); the real registry
                          is gitignored because it names one person's boards)
site/                     index.template.html — the GitHub Pages landing page;
                          assets/ — its photos, hero video and poster PDF,
                          the only part of the page not derived from firmware
.github/workflows/        ci.yml validates checks, then builds one job per
                          environment in parallel (`verify` is the required
                          check that judges them); pages.yml publishes
                          the site from the same firmware set;
                          release.yml publishes a tagged release with
                          every firmware image attached
docs/                     SD_CONTENT_SPEC.md, PORTING.md, screens/,
                          boards/ (one page per supported board; the pin
                          tables and diagrams in it are generated)
cases/                    printable enclosures, one folder per BOARD_NAME;
                          optional -- a board is supported without one

Cutting a release

A release is a tag. Everything else is automatic:

git tag -a v5.0.1 -m "Braino! 5.0.1" && git push origin v5.0.1

.github/workflows/release.yml then builds the five product environments -- tools/envs.py --product, never a list in the YAML -- packs them with tools/pack_release.py, and publishes a GitHub release with all four parts plus a single -merged.bin per environment, SHA256SUMS.txt and FLASHING.txt. The diagnostics are deliberately not attached; they are built from source by whoever is holding the board.

Before tagging, on main:

  1. include/AppVersion.h carries the real version -- not a -SNAPSHOT. The workflow refuses to publish one, because dev carries a snapshot between releases by design and the first mistaken tag would otherwise publish a "release" the firmware itself calls unreleased.

  2. CHANGELOG.md has a ## <version> section with that day's date. The release notes are lifted from it verbatim -- notes written by hand are a second changelog that agrees with the first only on the day it is written.

  3. The build figures in README.md and CLAUDE.md are from a build of the branch being released. tools/build_stamp.py compiles the branch name into the image, so a figure measured on a feature branch is a few bytes out on main; measure with GITHUB_REF_NAME=main pio run -e app, which is what CI does.

    BRAINO_VERSION is in the image too, so measure after the version bump, not before. Dropping -SNAPSHOT is nine characters and moved the 5.2.0 figure by 16 bytes, which shipped to main wrong because the build was run on the tree as it stood before the release commit. The consequence is that dev and main legitimately carry different numbers between releases -- 2,376,053 on 5.6.0-SNAPSHOT against 2,371,981 on 5.5.1 -- and that is not drift to be reconciled. check_docs.py compares each document against whatever .pio/build/app/firmware.elf is sitting in your tree, so each branch has to state its own figure or the checks fail for anyone who builds it.

    The figure is also not portable across hosts: the same commit is 172 bytes smaller in flash and 48 smaller in RAM on the Linux runner than on the Windows machine these numbers were read from. Pinning the platform and the libraries fixes what the build is made of, not which toolchain binary assembles it. Read the number from your own pio run; do not copy one out of a CI log.

The tag and BRAINO_VERSION must agree, and the workflow fails if they do not. That check exists because a published release cannot be quietly corrected: people have already downloaded it.

Afterwards, open the next version on dev as a -SNAPSHOT, so a board flashed from dev cannot be mistaken for the release it is ahead of.

The web installer is part of the deliverable

https://iamankushpandit.github.io/Gume/ flashes a board from the browser over Web Serial. .github/workflows/pages.yml builds all four PlatformIO environments on every push to main, runs tools/gen_site.py, and drops the resulting .bin files beside the manifest that points at them. Nobody regenerates it by hand, so it cannot go stale on its own — but three things break it, and all three fail in someone else's browser rather than here:

  1. Adding or renaming a PlatformIO environment. The page offers one firmware per entry in gen_site.py's VARIANTS; if the workflow does not build that env, its manifest points at binaries that do not exist and the flash fails partway. check_docs.py cross-checks VARIANTS against platformio.ini and the workflow.
  2. Changing what the firmware transmits or stores. The page carries a privacy section, and the same rule applies to it as to the About radio page and the README: a privacy claim that has drifted from the hardware is worse than none, because it is believed.
  3. Making CI unable to build. platformio.ini is expected to build on a clean checkout, locally and in GitHub Actions. If lib_deps changes shape, keep both .github/workflows/ci.yml and .github/workflows/pages.yml building all four environments in the same commit.
  4. Touching site/assets/. The photos, the one-minute hero video and the A2 poster are the one part of the page that is not derived from the firmware, so they are the one part that can rot with nothing noticing. Both directions are checked, for the same reason the stills are: gen_site.py refuses to generate when the page references an asset that is not there (a broken image on the landing page) or when an asset nobody references is sitting in the tree (weight in every clone, forever). gen_site.py copies the folder into the build, so nothing on the page reaches outside the Pages origin for a picture.

python tools/gen_site.py writes site/_build/ locally so you can look at the page. The flash button will 404 there — the binaries only exist in CI.

The page offers older releases, and must keep doing so

The installer used to offer exactly one firmware: whatever main last built. When 5.5.0 shipped a defect that made the two 2.8-inch boards untouchable, the only thing anyone could install was the broken one. A page that can only install the newest build has no way back from a bad release, which is the one moment somebody needs one.

tools/fetch_release_firmware.py downloads recent published releases' binaries into site/_releases/; gen_site.py publishes each under firmware/<board>/<env>/v/<version>/ with its own manifest, and the page grows a Version selector. Four things about it are load-bearing:

  • The bytes are copied, never rebuilt. What is served for 5.4.0 is what was published as 5.4.0, so an old version cannot quietly become a new build of old source, and a tag that no longer compiles today is still installable.
  • They are copied rather than linked because of CORS. The obvious implementation -- a manifest pointing at github.com/.../releases/download/... -- does not work: GitHub's asset host sends no Access-Control-Allow-Origin header at either hop (measured), so esp-web-tools' fetch() is blocked after the board is connected and the flash has begun. Serving from the Pages origin removes the question.
  • The version list belongs to the board, not the page. A board added in 5.4.0 has no 5.2.0 build, and a release that predates it must not appear in its dropdown. Switching to a board that lacks the selected version falls back to latest rather than leaving a stale manifest armed -- the same failure the firmware dropdown had once already.
  • A release missing any of the four parts is skipped entirely rather than half-published. A partial build fails after the erase has started.

check_docs.py enforces the three pieces staying joined up: the workflow step, the template's selector, and gen_site.py reading the cache. Dropping any one of them leaves a page that still generates, still deploys and still flashes the current version perfectly; the symptom appears only on the day somebody needs an older one.

Hardware notes

Pins live in include/boards/<board>.h -- one profile header per supported board, and the only place in the tree that names a GPIO. Read the profile rather than trusting generic ESP32 pinouts online, and never copy a pin map between CYD variants: GPIO34 is battery sense here and the light sensor on the ESP32-2432S028R. docs/PORTING.md is the checklist for adding a board.

  • A panel can be colour-inverted, and TFT_eSPI's macro will not fix it. A CYD panel can invert every channel relative to the driver it is built with: the board comes up with backlight, touch, layout and a clean serial log, and only the colours are wrong, which reads as a theme bug. TFT_INVERT_COLORS is consulted only by TFT_eSPI's ST7789 and ST7735 init paths, so defining it on an ILI9341 build does nothing at all -- that was tried on the bench and looked like the flag being ignored. The fix is PanelProfile::invertColours, which Board::begin() turns into a runtime invertDisplay() after init() and before anything is drawn.
  • The boot log states what the board IS. [boot] device=... is the firmware's own id (Board::deviceId()), never the MAC -- see "No identifiers in this repository". board= and panel= are compiled in and therefore describe the firmware, not the hardware, which is exactly how a 2.8-inch board reported itself as a 4-inch for half an hour.
  • A running board answers identify with the same facts, unreset. One line, every value quoted; ESP32_boardUtil.py asks before it resets anything. It carries only the banner's facts (no MAC, profile, score or SSID) and needs no PIN. Open the port with DTR and RTS already low, or opening it resets the board and defeats the point.
  • The serial port is a console, and it was widened on purpose. The maintainer asked for bench boards to be set up from a cable rather than by hand, and plans a layered framework whose shell reaches every service the UI does (docs/FRAMEWORK_PLAN.md on the platform-separation branch), so the console is built to grow. It is three files: AppRuntimeConsole.cpp (the reader, the command table, the session and Wi-Fi), AppRuntimeConsoleSettings.cpp (every device setting) and AppRuntimeConsoleProfiles.cpp (players and their games), with parsing in ConsoleText.h.
    • One line reader, one command table, one reply grammar. A command is a row -- name, usage, help, AppCapability, argument counts, handler -- and help is derived from the table. Every reply is exactly one line, ok key="value" ... or err <code> <message>, and reply keys are word characters only (ntp_hours, not ntp-hours), because every tool matches (\w+)="...". Add a command as a row, never as another string match, and never give it a second reply shape. identify?, settings and settings? are kept as aliases because tools on dev already send them; ESP32_boardUtil.py also accepts the [ident] ... reply that pre-console snapshot builds give.
    • CRUD, where the entity has it. Settings are fixed keys, so they are Read and Update: get [key] and set <key> <value> over one settings table whose rows are exactly the settings the Settings and Wi-Fi screens offer, with those screens' choices -- a new setting is a row there too. Players are full CRUD: profiles, profile-add, profile-rename, profile-remove. Per-player games are Read and Update: games <slot>, game <slot|all> <id> on|off (all is the classroom case). Wi-Fi is create/update and delete (wifi, wifi clear), read only as a flag.
    • Device reads are open; writes, and reads of player data, need the PIN. needsUnlock() keys on the row's capability (settings, network, profiles, scores, factory reset), so a new row cannot forget the gate. profiles prints names, so it carries the profiles capability and is gated like a change. Three wrong PINs lock the console out for 30s, and only exactly four digits are judged.
    • The same refusals as the screens, and two more. The admin profile cannot be removed; the active player cannot be removed either (from a cable that would pull a profile out from under a running game); two players cannot share a name. Games at launcher index 32+ cannot be hidden yet -- visibility is a 32-bit mask and the catalogue is 38 -- and the console says so rather than answering ok.
    • Serial only. A console over Wi-Fi or BLE would be a new outbound flow under the closed privacy list.
    • It goes through the same doors as the screens: the same Board setters and the same refusals (Nearby without the beacon), then a repaint of whatever screen is showing the old value.
    • Nothing personal comes back unasked: get and identify carry no name; player names only from the PIN-gated profiles; never the network's name or password; and the line buffer is wiped after every command.
    • It is not activity: nothing touches lastActivityMs_, and Board::setBrightness() no longer lights the backlight over a sleeping panel -- unreachable from the slider, reachable from a cable.
    • Deliberately absent: factory reset, reading or clearing scores, changing the admin PIN or which profile is admin, peer labels, the update check and the NTP server. Each is a decision for the maintainer, not a convenience.
  • An EN reset can strand an E32R40T, and the flash tool now recovers it. After the reset button, an RTS reset from a serial tool, a USB power surge -- and, most often, the hard reset at the end of an upload -- the ROM loops flash read err, 988 or invalid header: 0x20000368 / RTCWDT_RTC_RESET every ~350ms with the panel dark. It is not the GPIO12 strap, which was the first guess (GPIO12 is also the shared MISO): the ROM prints boot:0x17, whose MTDI bit is clear, so 3.3V flash was selected. Nor is the image damaged: read back while looping, the bootloader at 0x1000 was byte for byte correct, and the failing read differed from it by three bits at the same address -- a marginal read at the moment of some resets. Cause not yet found. What recovers it without a battery pull is a reset that comes from download mode: esptool --after no_reset flash_id, then esptool --before no_reset --after hard_reset read_mac, repeating the pair (never the second step alone) if it fails. ESP32_boardUtil.py --flash asks every board it flashed for its build afterwards and runs that recovery on any whose serial shows the loop (check_boot()), so a flash no longer leaves a dark 4-inch board behind. By hand: capture the serial passively before reflashing; it cannot be flashed while looping.
  • There is no id= field in the banner, and putting one back needs more care than it looks. 5.9.0 printed the controller's ID register and broke the display on two of the seven boards: tft.readcommand8() writes an undocumented 0xD9, toggles CS mid-sequence and restores neither the address window nor MADCTL, so a panel whose MISO is not wired back is left mid-command and the next write inherits it. The symptom is the top bar crushed into a band at one edge and the rest of the screen never painted, while the loop reports 48fps, a 23ms worst frame and a flat heap -- every instrument saying the firmware is healthy, because it is. The corruption was in the panel, put there by the diagnostic. Across all seven boards the field only ever answered 00:00:00 or FF:FF:FF; not one returned a real ID, so it could not do the job it cost a panel to attempt. The remaining three lines turn "which board is this?" into a paste rather than an afternoon.
  • The E32R28T-1's RGB LED is red IO22, green IO16, blue IO17, from the vendor's pin table, and IO4 is its amplifier enable, active low -- not an LED. It was once declared rgb.r = 16, rgb.g = 4 on the belief that red and green were crossed; the observations behind that (orange came out green, purple came out cyan) are exactly what IO16 being green produces, and driving IO4 as an LED held the speaker's amplifier in shutdown. Common anode, so drive is inverted -- which the profile states rather than the driver assuming.
  • Touch is bit-banged SPI on the E32R28T-1 and the ESP32-2432S028 variants (the TFT owns HSPI); on the E32R32P and the E32R40T the XPT2046 shares the display bus with its own CS on GPIO33, and TOUCH_CS in the board section is what switches BoardTouch.cpp to TFT_eSPI's touch extension. Either way, 3-point affine calibration persisted in NVS behind a magic number. touch.pressureThreshold = 350, touch.hitSlop = 8 in the profile.
  • Backlight brightness floors at Board::BRIGHTNESS_MIN = 25 — at lower duty the panel is unreadable and a player could not see the slider to undo it.
  • audio.speakerPin = 26 on the E32R28T-1, the ESP32-2432S028 inverted-panel variant, E32R32P and E32R40T reaches the JST speaker connector via the ESP32 built-in DAC (DAC channel 2 = GPIO26). GUME_HAS_AUDIO_DAC 1 is set on all of them; the I2S peripheral drives the DAC directly via I2S_DAC_BUILT_IN with no external codec. The full cue vocabulary and the spoken boot phrase play from the same synthesiser as the Freenove FNK0104B. The codec path is GUME_HAS_AUDIO_CODEC 1 (FNK0104B only); boards with neither macro have no audio. maxVolume in BoardProfile.audio is 85 for the codec board and 75 for the bare-DAC CYD boards (unamplifed driver distorts above 75%). See src/hal/CLAUDE.md. Since 5.10.0 it is on for the E32R28T-1 and the inverted-panel CYD too, whose touch clock is GPIO25 -- DAC channel 1, the 5.5.0 collision. beginAudio() powers that channel down and returns the pad to the GPIO matrix, logging [audio] GPIO25 released: ..., and Board::begin() re-applies the touch pins after it; BoardConfig.h allows a touch pin on the non-speaker DAC pad and nothing else.
  • Wi-Fi/NTP is a non-blocking state machine driven by tickTimeSync() each frame, with a raw-UDP ntpUdpProbe() fallback for when lwIP's SNTP never answers. The success-path automatic resync interval is a cached global setting, 1–24 hours with a 6-hour default; boot sync, manual sync and failure retries are separate. Timezone comes from a named POSIX zone or public-IP lookup — routers don't advertise one in practice.

Conventions

  • Hit testing: Rect{...}.contains(touch.x, touch.y, TOUCH_HIT_SLOP). Rect is in Ui.h, TouchPoint in hal/TouchTypes.h.
  • Board facts come from BOARD (include/BoardProfile.h), never from a literal. A peripheral a board does not wire is PIN_NONE, and the caller guards with BOARD.hasSdSlot(), hasRgbLed(), hasSpeaker(), hasBatterySense() or hasBacklightControl().
  • Feedback: board.beepOk() / board.beepError().
  • Draw through Ui:: helpers so every theme is respected; avoid hardcoded colours outside icon art. There are nine, and Ui::setTheme() fills the live palette from one table -- so a screen that reaches past the accessors is a screen that looks wrong in eight of them. Bar text, the three launcher tile fills and the button corner radius are palette entries too, for exactly that reason: each was a constant, and each made a theme impossible until it moved.
  • src/games/CountryDataTable.cpp is generated — edit tools/gen_country_facts.py and regenerate.
  • swallowTouch_ in main.cpp suppresses the first press after a rotation change or screen-saver dismissal, preventing a phantom tap on freshly drawn UI.

Part of Braino! by iamankushpandit. Copyright © 2026 iamankushpandit, licensed GPL-3.0-or-later alongside the code — reuse of this document, in whole or in part, must keep this attribution and stay under the same licence. See NOTICE.md.