Development Guide
Note
These instructions were written with macOS in mind. It should work elsewhere, but we have not personally verified this.
We hang out over in the Grout Development Channel on the RomM Discord. Come join us!
Prerequisites
Local Development
- Go v1.25.6+
- Task (for running the handy build scripts)
- SDL2 Shared Libraries (can be installed via Homebrew)
Local Builds / Packaging
- Docker Desktop or equivalent
- We love OrbStack (not a sponsor)
Getting Started
- Clone the Grout repository.
- Run
task code:hooks-setupto install git hooks. - Make a copy of
.env-devand save it as.envin the root of the cloned repository. - Fill out the
.envfile. Here are descriptions of the various values you can set.ENVIRONMENT=DEV(mandatory), this will disable some Gabagool features behind the scenesWINDOW_WIDTH(optional)WINDOW_HEIGHT(optional)NITRATES[true | false] (optional) This is used for Gabagool development debuggingCFW[NEXTUI | MUOS | KNULLI | SPRUCE | ROCKNIX | TRIMUI | ALLIUM | ONION | KORIKI | ARKOS | BATOCERA | MINUI | ANBERNIC | LEAF] (mandatory), this controls how Grout interacts with and places filesBASE_PATH(mandatory), this acts as the root path like you would have on a handheld (e.g./mmc/sdcardon muOS). Have the subdirectory structure of this path match the CFW you are working on.
- Run / Debug
app/grout.go, making sure to reference the.envfile in your run configuration.
Project Structure
The codebase is laid out fairly well. It attempts to keep everything grouped by feature / function / domain.
appcontains the glue, the main function, finite state machine for screen transitions, and the setup / cleanup code.bioshandles BIOS file operationscachecontains the logic for the SQLite database that powers the local cachecfwcontains all the logic for adapting Grout to the various CFWs that are supporteddocsfor the user guide and other repo housekeeping, including this document!libraryholds the game and platform types grout works in once data has left the RomM client. No I/O and no device knowledge, so identity and display stay apart.settingsthe user's configuration and the servers they connect to, plus loading and saving itcatalogorchestration that spans the cache and the RomM client, such as which platforms are mappedsavesthe save sync functionalityarchive,hashing,files,imaging,textmatch,tablesstandalone utilities with no grout dependencies. Each is named for what it does; there is noutilgrab bag.gamelistwrites the metadata each frontend readspspdbPSP Game ID to title lookup, used by save sync. Slated for removal in favour of Argosy Sigilresourcesthe splash screen image and localization files live here, along with the go file that embeds themromma client library for the RomM API.- Why wasn't this generated with the OpenAPI spec? We tried a number of the codegen tools for OpenAPI and they weren't compatible with version 3 of the spec and hacking around this limitation produced frustrating to use code.
scriptscontains the scripts (and metadata) associated with creating a package for each CFWuicontains the screens that the FSM references inapp/screens.go(transitions live inapp/transitions.go)updatehandles the in-app updater functionality, excluding the UIversionexposes the version information that is injected at build time. Having it as its own package made the script cleaner.
Packaging
The root taskfile.yml includes namespaced task files under taskfiles/ (build:, package:, deploy:, code:,
i18n:, media:) for building and packaging Grout for the various CFWs. Builds target ARM64, ARM32, x86 (32-bit),
and AMD64 Linux and use Docker for cross-compilation.
Quick Start
# Build and package for all platforms
task all
# Or build with a local gabagool workspace (for gabagool development)
task all LOCAL=true
Build Process
The build happens in two stages:
-
Docker Build - Cross-compiles the Go binary inside a Docker container. This ensures consistent builds regardless of your host OS and handles SDL2 dependencies.
-
Extract - Copies the compiled binary and required shared libraries (like
libSDL2_gfx) from the Docker container to the local build directory (e.g.build64/for ARM64).
Both stages run automatically when you invoke an architecture build such as task build:arm64 (also available:
build:arm32, build:x86, and build:amd64).
Platform-Specific Packaging
After building, you can package for individual platforms:
| Task | Platform | Output Location |
|---|---|---|
task package:all |
All platforms | Everything below |
task package:next |
NextUI (TrimUI) | dist/Grout.pak/ |
task package:muos |
muOS | dist/muOS/Grout/, dist/Grout.muxapp |
task package:knulli |
Knulli | dist/Knulli/Grout/ |
task package:spruce |
spruce / sprigUI / twigUI | dist/Spruce/Grout/ |
task package:rocknix |
ROCKNIX | dist/ROCKNIX/ |
task package:arkos |
ArkOS / dArkOS | dist/ArkOS/ |
task package:trimui |
TrimUI | dist/Trimui/Grout/ |
task package:allium |
Allium | dist/Allium/Grout.pak/ |
task package:onion |
Onion | dist/Onion/Grout/ |
task package:koriki |
Koriki | dist/Koriki/ |
task package:minui |
MinUI | dist/MinUI/Grout.pak/ |
task package:batocera |
Batocera (ARM64) | dist/Batocera-arm64/ |
task package:batocera-x86 |
Batocera (x86) | dist/Batocera-x86/ |
task package:batocera-amd64 |
Batocera (AMD64) | dist/Batocera-amd64/ |
task package:anbernic |
Anbernic Stock OS | dist/Anbernic/Roms/APPS/ |
task package:leaf |
Leaf (Miniloong Pocket 1) | dist/Leaf/Grout.pak/ |
Each packaging task copies the binary, launch scripts from scripts/<platform>/, shared libraries, and documentation
into the appropriate directory structure for that CFW.
Deployment via ADB
For rapid testing, you can deploy directly to a connected device, assuming that the device has ADB available:
# NextUI (TrimUI devices)
task deploy:next
# muOS (SD card 1 or 2)
task deploy:muos-sd1
task deploy:muos-sd2
# Knulli
task deploy:knulli
These tasks push the freshly built package over the existing installation, so the device keeps its config.json, cache
and other state. A device with no config gets config-platformless.json. To start clean, pass --fresh, which removes
the installation first:
Local Gabagool Development
When developing gabagool alongside Grout, pass LOCAL=true:
task build:arm64 LOCAL=true # Build using local gabagool via go.work
task all LOCAL=true # Build and package all platforms with local gabagool
This relies on the committed go.work file in the repository root, which references both projects, Grout (.) and a sibling ../gabagool checkout, so make sure gabagool is cloned alongside Grout.
Output Structure
After running task all, compiled binaries land in per-architecture directories and all packages in dist/:
build64/ # ARM64 binary + shared libraries
build32/ # ARM32 binary
buildx86/ # x86 (32-bit) binary + shared libraries
build/ # AMD64 binary + shared libraries
dist/
├── Grout.pak/ # NextUI package
├── Grout.muxapp # muOS archive (ready to install)
├── muOS/Grout/ # muOS package (unpacked)
├── Knulli/Grout/ # Knulli package
├── Spruce/Grout/ # spruce package
└── ... # one directory per remaining CFW
Helper Tools
The task files under taskfiles/ include several utility tasks for common development workflows.
Internationalization (i18n)
Grout uses go-i18n for localization. The workflow for updating translations:
# Extract new messages and find missing translations (recommended)
task i18n
# Or run steps individually:
task i18n:extract # Extract messages from source to active.en.toml
task i18n:merge # Compare against other locales, output missing to translations_todo/
The i18n task will:
- Scan the codebase for translatable strings and update
resources/locales/active.en.toml - Compare against each locale (es, fr, de, it, pt, ja, ru) to find missing translations
- Output any missing translations to
translations_todo/<lang>.toml
Code Quality
This runs go fmt, go vet, and staticcheck across the codebase.
Requires staticcheck to be installed (
go install honnef.co/go/tools/cmd/staticcheck@latest).
Package Layering
Grout is being reorganised into layers, and archcheck is what keeps that
direction honest. Go's compiler rejects an import cycle but says nothing
about direction, so nothing otherwise stops a device package importing the
HTTP client -- which is how the current shape came about.
The layers, each of which may only import the ones below it:
| Layer | What lives there |
|---|---|
pkg |
standalone utilities with no grout dependencies |
domain |
types and rules; no I/O, no device, no network. library and settings |
platform |
firmware knowledge: paths, gamelists, save layouts |
infra |
the RomM API, the SQLite store, the settings file |
service |
orchestration of a use case |
ui |
screens; may call services, not infrastructure |
cmd |
the composition root, and the only place with state |
Two extra rules: only ui and cmd may import the gabagool UI toolkit, and
only cmd may hold package-level mutable state.
The codebase does not satisfy this yet. Known violations are listed in
tools/archcheck/allow.txt, and the check fails both when a new violation
appears and when a listed one has been fixed but left in the file -- so the
list can only shrink.
If archcheck fails on your change:
- Fixing the import is almost always right. The error names the offending packages and the files that import them.
- If the violation is a deliberate intermediate step, add its line to
allow.txtwith a note in your PR explaining why. - If it says an entry is no longer present, you fixed something. Delete that
line from
allow.txt.
Regenerate the whole list after a large migration with task
code:archcheck-update, but read the diff -- the file should get shorter.
Media Conversion
# Convert MP4 video to animated WebP (interactive, prompts for paths)
task media:mp4-to-webp
# Resize all user guide screenshots to 1024px width
task media:resize-user-guide-images
The mp4-to-webp task is useful for creating animated preview images for documentation.
The resize-user-guide-images makes sure all the user guide screenshots are the same size.