Argosy is a controller-first game launcher for emulation handhelds and Android TV, built around the RomM backend. This file is the structural law of the project for ANY coding agent; CONTRIBUTING.md covers contributor expectations. Deep domain guidance lives in the skills named by the routing table at the end. Laws here carry their justified exceptions: a rule, its exception, why the exception is legitimate, and the boundary where it becomes a violation again.
- Layers: ui/ -> domain/ -> data/. Dependencies flow inward only.
- domain/ is Compose-free (hard rule). Android-framework-free is aspirational with known debt across 12 files (Intent in LaunchGameUseCase, Log in several use cases, and a whole media-codec pipeline in MeasureTrackLoudnessUseCase); the code-quality skill lists all 12. Existing debt is not a licence - do not add new framework imports.
- ui/ reaches game/platform/collection data through repositories, never GameDao/PlatformDao/CollectionDao directly (aspirational with a known violator list in the code-quality skill; do not add new ones).
- Decomposition: ViewModels ~500 lines then extract delegates; repositories ~300 then extract services; routers split method routing (see GameDetailViewModel + delegates/, SaveSyncRepository + services).
- Compose stability contract: app/compose_stability_config.conf declares data.model., data.local.entity., ui.screens., ui.components. and ui.dualscreen.** stable - those five, not all of ui/. ui.primitives is NOT covered despite holding FocusIndicators, InputGlyph and ConfirmModal. val-only state in covered packages; violations silently skip recomposition. Non-negotiable.
- Settings chain: DataStore key -> domain prefs repo -> UserPreferences aggregation -> SettingsModels state -> SettingsInitRouter hydrate -> owning delegate/router (gamepad A-press routes via SettingsConfirmRouter, not the section file) -> section render -> CONSUMPTION SITE. A setting with no consumption-site change is a ghost setting; trace the full chain.
- DB: schema change = migration in data/local/migrations/Migrations.kt AND an append to MigrationRegistry.ALL (or it silently never runs) AND a version bump AND the exported schema JSON.
- Dual-screen: OSOT. DualScreenManager StateFlows are the single source of truth; CompanionHost is the push channel; activities are consumers. Never anchor shared state in an activity.
- Off-main-thread: file, network, DB, and blocking native calls run on Dispatchers.IO, never main (GLRetroView serialize/destroy latch-block the caller until the GL thread runs). A progress overlay over a blocked main thread is a bug, not a mitigation - a frozen spinner means main is blocked.
Every user-facing feature, no exceptions unless justified in review:
- Dual-modality input: touch via clickableNoFocus AND gamepad via InputHandler with ViewModel-owned focus index. One modality = incomplete.
- Dual-screen parity considered (built OR explicitly deferred with reason).
- TV/AspectRatioClass behavior (TV has no touch): every action needs a controller mapping and/or a navigable menu path, never touch alone (e.g. favorites: gamepad button + game-details action + library menu).
- Sound feedback wired (InputDispatcher defaults or explicit override).
- Dimensions/colors from tokens (tokens.json -> generated), never literals.
- Footer hints follow control-is-the-guide (non-obvious hints only).
- Empty/error/loading states exist.
- On-device verification for anything the completeness matrix touches.
- Save sync (data/sync/, save repositories): incredibly complex; every door into it (pre-launch negotiation, session-end archiving, reconcile) requires live-data proof - GET /api/saves, negotiate no_op twice. Code-reading is never sufficient evidence of correctness here.
- Hardcore/RA compliance: strict by intent; the ra-compliance skill is the authority; do not loosen rules as a side effect of other work.
- Native red zone: libretrodroid/ (C++/JNI/GLRetroView/rcheevos bridge) is maintainer-locked; do not modify without maintainer discussion.
- Maintainer domains (not open without prior discussion): social, netplay, music/BGM. Releases are maintainer-only.
- Recovery before rejection. For a malformed archive, an unexpected layout, a stale or mis-keyed row: the order is repair, then ignore, then discard. Discarding needs a stated reason repair is impossible, not merely harder, and a choice between "accept it" and "throw it out" is not a complete set of options until you have established whether the data can be placed correctly from what is already known. The legitimate exception is data that identifies nothing - if it cannot be distinguished from another game's or another user's, refusing is correct. The boundary: "no mapping exists" is a finding, "I did not want to write the mapping" is not.
- A guard added to one path is unfinished. When adding a validation, a gate, or a repair, enumerate every other path reaching the same mutation and state which are covered and which are not. A check on the download path that the cache-restore path lacks is a half-built guard, and the uncovered path is where the damage lands.
libretro / RetroArch / core / RetroAchievements identifiers and semantics (core ids, option tokens, save formats, memory maps, hardcore rules) are verified against upstream sources (libretro docs, core repos, RetroArch source, vendored rcheevos) - NEVER inferred or analogized. Some in-repo maps are deliberately upstream-exact (RetroArch core display names for on-disk folder resolution); "tidying" them breaks resolution.
- Platform/emulator registries: Glob app/src/main/**/Registry.kt (14 today; the platform-support skill lists roles). Always scope to app/src/main - an unscoped glob picks up worktrees and build output. Core three: PlatformDefinitions, EmulatorRegistry, LibretroCoreRegistry - PlatformDefinitions does NOT match the glob, so finding 14 is not finding everything.
- Two resolvers that MUST agree: EmulatorResolver.getEmulatorPackageForGame and GameLauncher.resolveEmulator.
- Sync engines: SyncCoordinator (negotiate/reconcile), SaveSyncOrchestrator (discovery/preamble), SaveSyncRepository facade + services.
- Save id -> on-disk path, per platform: docs/save-id-to-path.md. Read it before touching a save handler; the id-to-path rules are upstream-exact and a changed archive shape invalidates every save already on a server.
- Input: InputDispatcher + per-screen InputHandler; index wrap via .mod(). No Compose focus for navigation or selection - focusable() appears nowhere and must not. The legitimate exception is FocusRequester for soft-keyboard text entry and the root key sink in ArgosyApp; it becomes a violation the moment focus decides what is selected rather than what is typed into.
- Tokens: design-system-docs/tokens.json -> scripts/gen-tokens.mjs -> ui/theme/generated/*.
- File access: FileAccessLayer + Manage Storage permission. SAF is deliberately not used (assumes no Manage Storage; blocks the permissive-device workaround). Unreadable is not absent.
- Session layer: PlaySessionTracker + GameSessionService (all emulators, persistent app layer).
- Any new feature request -> the investigate skill (scoped plan before code).
- Coupled-change axes -> .claude/skills/investigate/coupling-map.md.
- Writing any code -> code-quality skill. Settings/modals -> menu-patterns.
- Dimensions/colors -> design-tokens. Dual-screen -> dual-screen skill.
- Platforms/emulators/cores -> platform-support. RA -> ra-compliance.
- Contributor expectations, smells, PR evidence -> CONTRIBUTING.md.