Hackagotchi is a debug probe that's also a black-box recorder and an OLED dashboard, all on one single-core RP2040. So the bar for a change is a little unusual: it must not break the one guarantee that makes the whole thing work — the probe never stalls. This guide is how to contribute without tripping that wire.
- 🛠️ The shipping product is the C firmware in
firmware/c/. (firmware/micropython/is the legacy v1 prototype — not where new work goes.) - 🚫 Never edit
upstream/— it's a fetched, gitignored copy ofdebugprobe. We overlay, we don't fork-in-place. - ⛔ Nothing may block the DAP (probe) hot path. This is the R1 invariant; most review feedback is about it.
- 🔬 Prove hard claims on hardware (a gate) before building on them. Don't trust a green you didn't first watch go red.
- 🧱 Build:
./setup.sh && ./build_fork.sh. Gate:./analyze.shmust pass. Then run the relevant HIL tests. - ✍️ Commit small, atomic, signed-off (
git commit -s), conventional-commit style.
The details for each of these are one click away below.
1. Philosophy — why the code looks the way it does
- Gates-first. Before a feature, the risky architectural question gets proven on real hardware as a falsifiable gate (claim → quantified bar → machine assertion → adversarial variant → verdict). The enemy isn't a crash — it's a silent pass (a soak that counts nothing, a test that can't fail). See
docs/mcu-bringup-playbook.mdandfirmware/c/tests/gates/GATE_RESULTS.md. - Reuse over reinvent. We fork
debugprobe, vendor carlk3 FatFs, jsmn, ssd1306, ws2812 — and stay rebasable on upstream. Prefer a well-established library + an upstream contribution to a bespoke wheel. - Don't gold-plate. Document an accepted, understood caveat rather than chase it into a fork of pristine upstream code. (The retryable-DAP-fail caveat is the canonical example — see release-readiness.)
2. The R1 invariant — the rule that governs most reviews ⛔
The RP2040 here is single-core (debugprobe-v2.2.3, no SMP). Coexistence is bought with task priority, not a second core:
high ──────────────────────────────────────────────► low
UART bridge / watchdog > USB (TUD) > DAP > dashboard + SD writer
R1: a flash in progress must never stall. Concretely, when you touch firmware:
- Nothing on the DAP/USB hot path may block. No
sleep, no busy-wait, no blocking I/O, no taking a lock the DAP path also needs — in any task at or above DAP priority (the bridgecdc_task, the TUD callbacks). Long/blocking work goes in a lower-priority task (dashboard, SD). - One owner per resource. FatFs is touched only by the SD task; the OLED/I2C1 only by the dashboard task; uart0 TX only by the bridge task. Don't reach across a task boundary into someone else's resource.
- No shared mutable structs across tasks. Move data by single-writer published snapshots (the seqlock in
sd_gate.c/dash_get_rec_snapshot) or lock-free SPSC rings (spsc_ring.h). A reader gets a consistent copy; it never locks the writer. - Async request → reply-on-a-later-call for anything that must run on the owning task (e.g. SD
ls/cat/config-save are posted from CDC1 and serviced by the SD task, never inline). - The "0 stalls" bar is non-negotiable. Retryable (0-stall) DAP transfer errors under an artificial max-SD soak are an accepted, documented caveat; an actual stall (hang / corruption / transfer desync) is a release blocker.
The detailed conventions (error-code/goto-cleanup idiom, stack sizing, the crash box, the watchdog) live in docs/firmware-conventions.md — read it before a non-trivial firmware PR.
3. The fork-overlay model — we overlay upstream, never edit it 🚫
firmware/c/ is an overlay on upstream debugprobe, not a vendored copy:
setup.shfetches pristinedebugprobe-v2.2.3(+ carlk3 FatFs, pico-sdk) intoupstream/(gitignored).CMakeLists.txtcompiles our files instead of the matching upstream ones (e.g. oursrc/main.c), and putssrc/+boards/first on the include path so our headers shadow upstream's. The board pin map is injected via aboards/board_debug_probe_config.hshim — never by editing upstream.- So: never modify anything under
upstream/. If you need to change upstream behavior, copy the file intosrc/, add it toCMakeLists.txt, and document what changed in its header (so the next upstream-bump diff is obvious). Seedocs/upstream-strategy.md. - Upstream-worthy fixes should go upstream (to
raspberrypi/debugprobe/ yapicoprobe) — keeping thefirmware/c/subtree MIT exists precisely to make that easy.
4. Dev setup — pinned toolchain + a bench rig for HIL
You need the pinned toolchain (the build refuses anything else):
- Arm GCC 13.3.Rel1 and pico-sdk 2.2.0 — see
docs/c-firmware-build.mdfor paths /GCC_DIR=/PICO_SDK_PATH=/FW_BUILD_DIR=overrides (or drop a gitignoredfirmware/c/build_fork.local.sh). cmake≥ 3.13,picotool2.x, and (for tests)probe-rs+openocd. Optionalcppcheck.- A bench rig for HIL: the XIAO probe, a target RP2040 wired SWD (GP26/GP27 + GND), a microSD card, and Python
pyserialfor the test harnesses.
cd firmware/c
./setup.sh # fetch pinned upstream into upstream/ (gitignored)
VERSION=$(git describe --tags --always) ./build_fork.sh # -> build/hackagotchi_probe.uf2 (+ .elf)
./analyze.sh # static-analysis gate — must passFlash: BOOTSEL the XIAO (hold B, tap R, release B) then picotool load -x build/hackagotchi_probe.uf2 — or, on a running device, {"q":"bootsel"} over CDC1, then load.
5. Building blocks — how to add a command, a screen, or a gate
- A CDC1 command: add a
{"q":"..."}branch insrc/cdc1_control.c. Keep reply buffersstatic(the TUD task stack is small). If it touches SD/recorder/OLED state, post a request to the owning task — don't do the work inline. Document it in the README command table. - A dashboard screen: add a
screen_*fn insrc/hackagotchi_dashboard.c, register it inSCREENS[], and read only the published snapshot (neverg_rec/FatFs directly). Monitor screens auto-cycle; tool screens go pastN_MONITORand are summoned over CDC1. Emit the text model for self-attestation. - A gate / HIL test: add it under
tests/gates/ortests/mN/. Make the assertion decide on the right signal (parse the authoritative line, not a progress substring) and feed it a known-bad input once to watch it fail before you trust the pass. Record the verdict in the matching*_RESULTS.md.
6. Testing & verification — host tests in CI, HIL on the bench (+ the gotchas that bite)
- Host unit tests (no hardware) for portable logic — e.g.
cc -I src -pthread tests/m1/ring_test.c -o /tmp/ring && /tmp/ring,cc -I src tests/m2/recorder_test.c src/recorder.c -o /tmp/rec && /tmp/rec. Add one when you add portable logic; these can run in CI. - HIL tests for anything touching hardware/timing/USB —
tests/{gates,m1,m2,m3,m4}/*.py, run with the project venv python. They cannot run in CI (they need the bench); attest them by hand on the image you're proposing and note results indocs/release-readiness.md. - Bench gotchas (these bite everyone):
- The
/dev/cu.usbmodem*suffix isn't stable — find the control port by behavior (answers{"q":"status"}asHackagotchi); the other is the bridge. - Open the bridge (CDC0) first and let it settle ~0.6 s before driving control/loopback (opening it re-inits uart0). Keep connections persistent; churning a CDC port wedges macOS USB-CDC.
- Run soaks on an idle host (host load inflates the retryable-fail rate) and power-cycle the target between long soaks (some boards re-glitch their QSPI under sustained hammering — still 0 stalls).
- Run the UART-loopback tests standalone on a settled device, not back-to-back after the reboot-inducing crashbox/watchdog tests.
- The
- The static-analysis gate is a hard gate.
analyze.shFAILs on any-Wanalyzer-*finding or any warning in the genuinely-new files (hackagotchi_dashboard.c,cdc1_control.c). Keep those pristine.
7. Commits & pull requests — small, atomic, signed-off ✍️
- Small, atomic commits, each one building + passing the gate on its own.
- Conventional-commit subjects:
feat(scope): …,fix: …,docs: …,test: …,chore: …. - Sign off every commit (
git commit -s) — we use the DCO; your sign-off certifies you can contribute the change. - Enable the shared hooks once per clone:
git config core.hooksPath .githooks. Thepre-commithook keeps per-developer AI-assistant context files (CLAUDE.md,GEMINI.md,.cursorrules, …) out of the repo — they're personal (use whatever assistant/style you like) and already.gitignore'd; the hook just blocks an accidentalgit add -f. - Open a PR against
mainwith: what changed and why, which R1 rule it touches (if any), and the test evidence — host-test output and/or the HIL*_hil.pyPASS lines (or "needs bench: <which>" if you can't run them). CI (build +analyze.sh) must be green. - For firmware changes, say explicitly whether anything runs on or above the DAP path — reviewers look there first.
8. Licensing of contributions
- Contributions to
firmware/c/are MIT (to stay upstream-rebasable). New first-party source files there get anSPDX-License-Identifier: MITheader. - Contributions elsewhere are GPL-3.0-or-later (the project license).
- Don't add a dependency under a copyleft (GPL) or non-commercial (e.g. SEGGER SystemView) license to the shipping image; if you vendor a new permissive dep, add it to
THIRD-PARTY-NOTICES.mdwith its license.
Open a GitHub issue. For the deep "why," the sources of truth are docs/engineering-plan.md (the plan + 3-gate model), docs/firmware-conventions.md (the codified rules), and docs/release-readiness.md (what's proven, and how). 🐾