MaD Coding Guidelines¶
These are the coding standards for the MaD monorepo. They are grounded in the actual codebase — the conventions, patterns, and constraints below reflect how the code is really written and gated, not aspirational rules. Contributors and AI assistants are expected to follow them when adding or changing code anywhere in this repository.
The deep, language-specific detail lives in the per-language guides. This index covers only the cross-cutting principles that apply everywhere; start with the guide for whatever you're touching.
Guides¶
- C / Firmware (Propeller 2) — MISRA + cppcheck — Hand-written C under
Firmware/MaDCore/src/: template/banner layout, layer-prefixed naming, MISRA-friendly idioms, the strict downward layer rule, the non-reentrant HAL try-lock + double-buffer concurrency pattern, cog-manager channels, and howpio checkis wired (medium+high blocking; low disabled). - Rust / SIL (MaD crates + embsim workspace) — The Software-in-the-Loop layer under
SIL/: the generic-framework vs MaD-consumer split (embsim is now a standalone repo vendored atSIL/embsim), Cargo workspace inheritance, the FFI/unsafeHAL boundary, std-only hand-rolled error handling, static/atomics/Mutexconcurrency, the do-not-edit generated protocol crate, and the CI gates (sil-rustrunscargo test; embsim's own CI gates the submodule). - Python (build hooks + ProtoEmb generator) — The PlatformIO SCons helpers (
Firmware/MaDCore/extra_scripts/) and the conventions shared with the ProtoEmb code generator (now a standalone repo vendored atProtocol/ProtoEmb): the dependency-light stack (pyyaml + jinja2), argparse CLI shape, schema-enrichment key convention, theSystemExit-vs-ValueErrorerror model, the hardened YAML loader (keepsOFF/ONas strings), and Jinja2 conventions. - Protocol YAML schema (MaDProtocol.yaml) authoring conventions — Authoring
Protocol/MaDProtocol.yaml, the single source of truth the generator turns into byte-identical C, TypeScript, and Rust codecs: top-level layout, naming/casing, the field type system, the message routing/timing table, the wire-format contract, versioning/append-only rules, and the regenerate-all-three-targets workflow. - Vibes behaviour declarations (the test-derived ledger) — Annotating tests in TS/C/Rust so they publish claims to
behaviours.jsonl: writinggiven/thenfor a reader who has never opened the repo (no internal type names or parameter letters), stableidsemantics (reword freely, rename never), declare-on-entry and the C macro-placement rule, per-language binding snippets, and thecollect --write+ commit-the-ledger workflow.
Shared principles¶
These apply repo-wide regardless of language. Each guide expands them in its own context.
Respect layering and separation of concerns¶
The firmware has a strict layered architecture — each layer only calls the layer below (APP → DEV → IO → Library → HAL → HW). Never include or call low-level MCU headers from APP/DEV/IO; go through HAL. The same separation discipline applies elsewhere: in the shipped app keep the UI ↔ Web Worker boundary clean (the serial read loop and all protocol/WASM work live in the worker; src/ stays pure Web Serial + File System Access, with test fakes confined to e2e/); in SIL keep the reusable embsim framework crates free of MaD-specific assumptions, with MaD specifics confined to the consumer crates and FFI/unsafe confined to the platform crate.
Never hand-edit generated code¶
Three directories are generated from Protocol/MaDProtocol.yaml and must never be edited by hand:
Firmware/MaDCore/src/Generated/Software/Control/src/protocol/generated/(the deployed app)Protocol/rust/src/generated/— the Rust target output (perSIL/makefile:33); holdsprotoemb.rs. (Some older docs referred toSIL/embsim/peripherals/src/generated/, which does not exist — use theProtocol/rust/src/generated/path.)
To change anything in them, edit the schema (or the Jinja2 templates) and regenerate.
Regenerate from the schema¶
After changing Protocol/MaDProtocol.yaml or the templates, regenerate all three targets so they stay in lock-step (run from repo root):
python3 ./Protocol/ProtoEmb/core/generate.py --schema ./Protocol/MaDProtocol.yaml --target c --output ./Firmware/MaDCore/src/Generated --templates ./Protocol/ProtoEmb/core/templates
python3 ./Protocol/ProtoEmb/core/generate.py --schema ./Protocol/MaDProtocol.yaml --target ts --output ./Software/Control/src/protocol/generated --templates ./Protocol/ProtoEmb/core/templates
python3 ./Protocol/ProtoEmb/core/generate.py --schema ./Protocol/MaDProtocol.yaml --target rs --output ./Protocol/rust/src/generated --templates ./Protocol/ProtoEmb/core/templates
Firmware also regenerates its C target automatically via the extra_scripts/generate_protocol.py PlatformIO pre-build hook. Commit the regenerated code alongside the schema change.
The protocol is the cross-language contract¶
The firmware, the control app, and the SIL emulator all speak the same wire format (serial, 2,000,000 baud on hardware). The schema-driven codecs are byte-identical across C, TypeScript, and Rust (C == Rust == TS) — that equivalence is the contract. Do not patch one language's codec to work around the others; fix the schema and regenerate. G-code motion profiles (G0, G1, G4, G28, G90, G91, G122) are streamed line-by-line, and tests/profiles that must signal completion should end with G122 where the firmware contract requires it.
Run the linters and checks before pushing¶
Each area has its own gate (see the checklist below). Run the relevant one locally before pushing. Firmware pio check enforces medium + high only (low severity is disabled project-wide); fix medium/high rather than suppressing.
Native vs P2 testing¶
Pointer sizes and timing differ between the Propeller 2 and the host. Run pio test -e native_test and a MaD build for firmware changes — passing on one target does not guarantee the other. On the SIL, the project files boot the same MaD image the hardware runs off the module's flash, and the mad-catalog/p2core/p2iss suites execute it too.
SIL is single-instance¶
Treat the emulator as single-instance. Don't write tests that assume parallel emulator instances.
Concurrency / locking discipline (firmware)¶
HAL locks are not reentrant, and a module must never call another module's API while holding its own lock (prevents self-deadlock and cross-cog ABBA deadlocks). IO_protocol and shared protocol/JSON buffers are not casually thread-safe across cogs; lib_staticQueue and Library data structures are unsynchronized by contract (lock-free only for SPSC use) — the owning module wraps ops in its own lock when its topology needs one.
Commit and PR conventions¶
Commits follow Conventional Commits with a scope: type(scope): subject — e.g. feat(protocol):, refactor(firmware):, test(sil):, fix(ci):, chore(protocol):. Keep changes scoped to one area where practical. Branch off main rather than committing to it directly. Releases are cut by pushing version tags (madcontrol-v* deploys MaD Control + docs to Pages and must match Software/Control/package.json; firmware-v* and hardware-v* cut those releases). Use Software/Control/scripts/release.sh rather than tagging MaD Control by hand.
Before you push¶
Run the gate for each area you touched:
| Area | Lint / static check | Tests |
|---|---|---|
| Firmware (C) | pio check -e MaD --fail-on-defect=medium --fail-on-defect=high (from Firmware/MaDCore/) |
pio test -e native_test |
| Software (TS/React) | npm run verify (from Software/Control/ — tsc + eslint + tests + build) |
included in verify (npm test, Vitest) |
| SIL (Rust) | cargo clippy + cargo fmt --check (from SIL/) |
make test (emulator + cargo test) |
| Protocol / generated code | — | Regenerate all three targets with generate.py and commit the output (re-run the schema's conformance/verify.sh check) |
If you changed MaDProtocol.yaml or the templates, regenerate before running the per-area gates above so each language is linting/testing against the up-to-date codec.