Skip to content

EPIC — App core: one mode machine, one card scheduler, one frame pass #1397

Description

@timohueser

Program tracker: #1448

#1448 owns the global order, current frontier, and cross-epic gates. This issue owns its local architecture and acceptance criteria.


This issue is the foundation of a rework epic for the application core in firmware/obc-app. The scope is the state machine of the device: how the app decides what state it is in, what may run, and when the display is redrawn. The scope does not include the drawing code of single screens, and it does not include the ride data domain.

All numbers below come from wc -l and grep on develop at e7f1b11.

1. What is wrong today

1.1 The honest size of the area

File Total lines Production Tests
src/app.rs 6,948 ~3,338 ~3,610
src/ui_runtime.rs 1,145 1,145 0 (tested from app.rs / integration)
src/activity.rs 1,453 ~970 ~483
src/host.rs 629 629 0
src/arena_gate.rs 393 225 168
src/hold_hint.rs 376 291 85
src/input.rs 306 174 132
src/reroute_freeze.rs 297 187 110
src/link_gate.rs 268 152 116
src/fault.rs 186 126 60
src/input_plane.rs 168 168 0
src/lib.rs + src/dirty.rs + src/device_status.rs 194 194 0
Total 12,363 ~7,600 ~4,760

Two starting hypotheses are refuted first, so the epic does not fight the wrong enemy:

  • "The 7,000-line central file" is more than half tests. The production half of app.rs is ~3,338 lines. It is already a composition root over components (RideEngine, UiRuntime, CatalogState, HostPending), exactly as docs/content/software/architecture.md describes. The docs and the code do not diverge on the large structure.
  • The ring modules (link_gate.rs, arena_gate.rs, reroute_freeze.rs, hold_hint.rs) are not careless bolt-ons. Each one is small, single-purpose, and unit-tested against a named regression.

The real problems are below. They are problems of multiplicity and ceremony, not of missing discipline.

1.2 Four modules answer one question

The question "what may run now, and what may draw now" has one answer at any moment. Today four mechanisms hold parts of that answer, and call-site discipline across two crates keeps them consistent:

Mechanism File What it guards Where it is checked
RerouteFreeze (flags route_live, detour_live) reroute_freeze.rs map redraw + matcher pause while a planner runs App::tick, App::take_dirty, App::render_overlay, board ride loop
TransferGate (owner + searching flag) link_gate.rs one transfer across two wires; search excludes transfer BLE + USB control planes, board ride loop
ArenaGate (owner: render / nav / usb) arena_gate.rs the shared RAM arena board arena.rs, per claim
transfer_screen_up() (stack scan) app.rs:1144 "the UI shows the transfer screen" usb_arena_precondition

The fact "a route search is live" exists as three independent flags: RerouteFreeze::plan_live(), TransferGate::searching, and ArenaGate::owner == Nav. The fact "a transfer is live" exists as three more: TransferGate::owner, ArenaGate::owner == Usb, and a Screen::MapTransfer scan of the stack. No type forces the copies to agree. The proof tokens (MapQuiesced, TransferReady) exist precisely to bridge the copies — they are the tax the split costs.

1.3 The modal-card rules are implemented seven times

ui_runtime.rs holds one delivery discipline for host-pushed cards: never land during a hold, the passkey card outranks, replace instead of stack, timeout means dismiss. That discipline is re-implemented per card:

Reconciler Lines Re-implements
reconcile_passkey_card ~40 defer-on-hold, outrank, push/remove, dirty
reconcile_map_transfer_card ~40 defer-on-hold, push/rewrite/remove, dirty
reconcile_upload_prompt ~70 defer-on-hold, outrank-drop, replace-in-place, id revalidation
close_expired_upload_popups ~25 defer-on-hold, timeout-dismiss
reconcile_warning ~30 defer-on-hold, outrank-defer, OR-into-open-card
reconcile_update_toast ~20 defer-on-hold, outrank-defer
on_dfu_scanned / on_dfu_install_began / on_dfu_install_failed ~40 find-and-replace card in stack

That band of ui_runtime.rs is ~354 lines (lines 619–972). Around it: hold_charging() is checked at 7 sites, the stack is scanned by position()/rposition()/find_map() at 14 sites, and debug_assert!(r.is_ok(), "screen stack overflow …") is copied 6 times. Each new card kind re-opens the same five rules by hand. The subtle differences between reconcilers (passkey removes popups, map-transfer does not; warning defers, upload-prompt drops) live only in comments.

1.4 The screen stack is mutated through three doors

  1. Gestures return a Transition, applied by screen::apply (app.rs:2440).
  2. Host-pushed cards push/remove through the reconcilers above.
  3. apply_climb_auto_switch (app.rs:1068) and apply_idle_return (ui_runtime.rs:1055) write the stack directly (*top = …, truncate(1)).

Every door must remember the same postconditions by hand: set map_dirty, cancel holds, re-arm the corridor scan. apply_gesture does all three; the climb auto-switch does none of them itself (its caller sets dirty). One more door added under time pressure is the classic source of a missed postcondition. This is the audit's lens 3 in live form: the mechanism exists (Transition), and code beside it bypasses it.

1.5 The repaint model is manual at 51 sites, with three edge converters

The rule is sound: over-redraw is safe, under-redraw is a bug (dirty.rs). The implementation is a convention: 51 map_dirty = true assignments (33 in app.rs, 18 in ui_runtime.rs), each placed by hand. Because sensor data lives outside the compared AppState, tick has grown private previous-value mirrors to detect edges: state_before, ride.prev_no_fix, ride.prev_live_sensors, plus per-quantity "is this field on the grid" guards (app.rs:905–929). Each new live readout needs a new mirror and a new guard.

The overlay plane has three separate level-to-edge converters: InputPlane::overlay_was_active, UiRuntime::overlay_edge, and RerouteFreeze::engaged_shown. Three small state machines produce one boolean per frame.

One stale document: dirty.rs:36 says the region mechanism serves only the nav-planning spinner. The map clock pill also reports a region (screen/map.rs:181). The mechanism is justified — a region-clipped minute tick avoids a full ~97 ms map render — but its own contract file lags the code.

1.6 The host protocol costs ~9 edit sites per command

The typed HostCommand/HostEvent vocabulary (host.rs) is a good design. Its pending state is hand-unrolled. One new async command touches: an Activity slot field, a take_* method, a pending_* peek, a HostCommandClass arm, a DRAIN_ORDER entry, a class() arm, a peek_host_command arm, a drain_host_command arm, and the variant itself. Measured: Activity has 11 take_* methods plus ~10 peeks; peek_host_command is 28 lines; drain_host_command is 86 lines; class() is 21 lines; DRAIN_ORDER is 20 lines — all four lists must stay in step by review.

1.7 The frame protocol is a prose contract across ~15 methods

App exposes 116 pub fn. The board ride loop (obc-fw-nrf54l/src/ride.rs, 2,464 lines) makes 104 app.* calls; 57 of them are the per-pass protocol: ticksample_terrainapply_gesture* → advance_animationstake_dirty → render → ms_until_next_wake, plus set_hold_progress, take_hold_cancel, base_needs_reader, reroute_freeze_active, nav_arena_precondition, drain_host_commands, apply_event. The ordering rules live in doc comments and one debug_assert: "call exactly once per frame or the trailing edge is swallowed" (take_dirty), "must follow advance_animations in the same frame, with the same now_ms" (ms_until_next_wake). A host cannot get the order wrong by type — only by luck and review. Several delegation pairs carry near-verbatim duplicate doc blocks (base_draws_map, base_needs_reader, take_dirty, take_hold_cancel each documented twice, on App and on UiRuntime).

Related: the fix-batch hold-drop in handle_input (app.rs:2376–2383) re-implements, at a second layer, the same #480 cancel that Gestures::cancel_holds already implements — a patch beside the mechanism instead of in it.

1.8 Placement init is hand-written three times

App generates its two constructors from one field plan (define_idle_constructors!, app.rs:606). Its components do not use that mechanism: UiRuntime::init_in_place (63 lines), CatalogState::init_in_place, and RideEngine::init_in_place are hand-written unsafe field-by-field twins of their new()68 addr_of_mut! writes total, each an opportunity for an uninitialized-field bug that only the trailing exhaustiveness guard catches.

2. Proposed architecture

The rebuild keeps what is proven — the two-plane input model, the render-on-demand rule, the typed command vocabulary, the screen Caps declarations — and replaces the multiplicity around them with one mechanism per question.

2.1 One activity-mode machine

One state machine owns the answer to "what may run":

CoreMode
├─ Free                       // render may claim the arena per frame
├─ Searching { family }       // route or detour planner holds the nav arm
└─ Transferring { wire }      // BLE or USB transfer holds the staging arm
  • The machine is the only writer of "a search is live" and "a transfer is live". RerouteFreeze, the searching flag on TransferGate, and ArenaGate's owner collapse into it. The wire owner (Ble/Usb) becomes data on the Transferring state; the per-wire release rule (P3b-2: volume-set transfer — manifest-last uploads over USB/BLE #1039) becomes one match arm.
  • The freeze becomes a derived view: frozen = mode is Searching && base_draws_map(). The banner edge derives at take_dirty exactly as today, but from one source.
  • The proof-token style stays at the board seam: claim_render / claim_nav / claim_usb keep their signatures, minted from the one machine, so the board's arena.rs union does not change.
  • Deleted: reroute_freeze.rs (state half), arena_gate.rs (ownership half), the search arm of link_gate.rs, transfer_screen_up, both *_arena_precondition derivations. The drawing half of the banner moves next to the other overlay chrome.

2.2 One modal-card scheduler

One table describes every host-pushed card; one engine runs per pass:

card kind      priority  on-conflict   timeout   revalidate
Passkey        high      replace-low   none      passkey still set
MapTransfer    high      rewrite       none      state still fed
UploadPrompt   low       replace-same  30 s      id still in catalog
Warning        low       or-into-open  none      fresh flags remain
UpdateToast    low       push-once     none      fact still present
DfuLanding     high      replace-slot  none      wait screen still up

The engine holds the five locked rules once: never move a card during a hold; higher priority wins; replace, do not stack; timeout dismisses; a vanished subject drops the card. The seven reconcilers, the 14 stack scans, and the 7 hold_charging checks reduce to one sweep plus one table. A card whose rule the table cannot express forces a table extension — a bespoke reconciler beside the engine becomes a review failure.

2.3 One frame-pass driver

One entry point replaces the prose contract:

let plan = app.pass(PassInputs { clock, sensors, gestures, route, .. });
// plan: { dirty, needs_reader, draws_map, wake_ms, hold_cancel, commands }

pass runs the fixed order internally (tick → sweeps → scheduler → dirty drain) and returns one value. The "exactly once", "same now_ms", and ordering rules become unreachable from outside. The existing fine-grained methods stay during migration as thin wrappers and are deleted in the last slice. The board ride loop, obc-host-core, the simulator, and the web demo all shrink at their call sites.

2.4 Declared render keys instead of 51 manual dirty sites

The conservative rule stays. The mechanism changes: each screen already declares Caps (base content, reader need, idle exemption). Extend that declaration with the render key — the small set of copyable facts the screen draws (camera, progress, next waypoint, live sensor display values, no-fix flag, battery on Home). The pass driver snapshots the key before and after the pass; a changed key dirties the map. Mutations that no key covers keep an explicit dirty call, and any un-declared doubt defaults to a full dirty — over-redraw stays safe. The hand mirrors (prev_no_fix, prev_live_sensors, state_before, the per-quantity grid guards) and most scattered sites are deleted. The three overlay edge converters merge into one in the driver.

2.5 A slot table for the host protocol

Keep HostCommand / HostEvent unchanged on the wire. Introduce one generic Pending<T> slot type (take, peek, annihilate-by) and one table that generates DRAIN_ORDER, class(), peek_host_command, and drain_host_command from a single row per command. A new command becomes one row plus one variant. The per-command drain hooks that carry semantics today (freeze edges, id resolution, retention revalidation) become named row callbacks, so nothing moves by convention.

2.6 One placement-init mechanism

Generalize define_idle_constructors! so a component states its field plan once and receives new() and init_in_place from it. The three hand-written unsafe twins are deleted.

What is deleted outright

reroute_freeze.rs (297), arena_gate.rs (393), the search arm and preconditions (~100 across link_gate.rs/app.rs), the seven reconcilers (~430), the protocol unrolling (~315), the three init_in_place twins (~170), the duplicated façade docs and wrappers (~150), the tick edge mirrors (~90). The stale dirty.rs region note is corrected in passing.

3. Net LOC, risks, performance

3.1 Estimated net LOC

Production lines only; tests port with their mechanisms and are budgeted separately.

Area Removed Added Net
A: mode machine (freeze + arena + search arm + preconditions) ~510 ~280 −230
B: modal scheduler (reconcilers + scans + guards) ~430 ~180 −250
C: protocol slot table (peek/drain/class/order + Activity slots) ~315 ~120 −195
D: frame-pass driver (façade wrappers + duplicate docs) ~150 ~130 −20
E: placement-init macro (3 hand twins) ~170 ~60 −110
F: render keys (edge mirrors → declarations) ~90 ~110 +20
Total (obc-app production) ~1,665 ~880 ≈ −785

That is ~10 % of the area's 7,600 production lines. The board's ride.rs and the three hosts shrink additionally (not counted; they are outside this crate). The larger effect is marginal cost: a new async command drops from ~9 edit sites to 2; a new modal card drops from a ~40-line reconciler to a table row.

Test budget: the four ring modules carry ~480 lines of regression tests. Every named regression (#480, #1039, #1146's stuck-freeze and family-release cases, the memset-skip cases) must have a ported test on the new mechanism before the old module is deleted. Expect roughly LOC-neutral tests.

3.2 Risks

  1. Lost special cases in the merged state machine. The quartet encodes hard-won rules: a detour's terminal edge must not release a route freeze; a teardown releases only its own claim; two live plans hold the freeze until both end. Containment: the union of the four modules' test suites is ported first and must pass on the new machine in the same PR; deletion of an old module lands only after its tests are green on the replacement.
  2. Big-bang pressure. Containment: the six areas are separate slices (A–F); each deletes its old path in the same PR (the EPIC — Device Object System v3: one engine, one flat store #1256 rule); the App façade stays stable until the final driver slice.
  3. Host breakage from the pass driver. Four consumers (board, sim, web demo, obc-host-core) call the frame protocol. Containment: old methods remain as wrappers through the migration; each slice re-renders the changed screens headless (obc-sim --png) and the final slice gets an on-glass soak.
  4. The scheduler table cannot express a future card and invites a bypass. Containment: the table's policy enum is extended in-table; a CI grep guard forbids stack.push/stack.remove outside screen::apply and the scheduler, so a bespoke reconciler cannot re-appear silently.
  5. Under-redraw from render keys. Replacing manual dirty sites is the one place the "under-redraw is a bug" rule is exposed. Containment: undeclared changes still force a full dirty; a simulator differential test renders every frame unconditionally and pixel-compares against the on-demand output across the replay corpus.
  6. Cross-crate mode ownership. The mode machine is app-owned but arbitrates board memory. Containment: the machine stays plain data with the same three claim entry points and proof tokens the board already uses; arena.rs (the one unsafe union) does not change.

3.3 Performance claim — stated plainly

This epic is performance-neutral by design. It improves structure, marginal feature cost, and defect probability — not speed, and not RAM in any measurable amount.

  • Redraw count: unchanged. The dirty semantics (conservative, region fold, overlay edge) are preserved; the differential test in risk 5 verifies equal frames, and the sim's redraw counters must match before/after per replay.
  • CPU: unchanged. The pass driver inlines the same calls the hosts make today; the mode machine replaces three flag reads with one; obc-bench --check hashes.txt gates the render path as always.
  • RAM: a few tens of bytes of duplicate flags and mirrors disappear (engaged_shown, overlay_edge, prev_* mirrors, the second search flag). This is noise against the ~117 KB arena and is not claimed as a gain. Resident App size is pinned before/after with the existing size assertions; growth is a review failure.

Equal-or-better is the gate, not the goal: the acceptance test for the epic is the LOC table above plus byte-identical rendering, unchanged redraw counts, and an unchanged resident footprint.

Dependency on #1256 (Device Object System v3)

  • Wait for FS5/FS7 before Area A and the storage command rows. HostCommand::ScanCardFree is defined as a FAT free-cluster scan; the flat store answers free space from its bitmap, so this command changes shape or dies. StampRouteUsed / StampRideSynced write an SD sidecar that FS7 relocates. RescanStore and the catalog feed follow the new catalog in FS6/FS7. Rebuilding those rows now would race the v1-path deletions.
  • The transfer-ownership half of link_gate.rs survives EPIC — Device Object System v3: one engine, one flat store #1256 by that epic's own acceptance ("BLE and USB stay byte-identical adapters on one engine"), but FS5 re-plumbs the engine under it — Area A should arbitrate against the new engine, not the dying one.
  • Areas B and E can start now. The non-storage vocabulary in C waits for DC7.
  • Area F waits for the final App::pass from S6. This avoids a resident key copy or host-specific tracking.

Slices

Slice Content Gate
#1445 S1 Modal-card scheduler + table; delete the seven reconcilers ported card tests green; grep guard in CI
S2 Protocol slot table (non-storage rows); delete the four hand lists protocol tests green; new-command demo in 2 sites
#1446 S3 Placement-init macro; delete the three init_in_place twins init_idle_matches_new_idle-style pin per component
#1447 S4 Render keys + one edge converter; delete the mirrors after S6 and V0; sim differential pixels green; redraw counts equal
S5 Mode machine (after FS5); delete freeze/arena/search-arm modules union of ring-module tests green; on-glass soak
S6 Frame-pass driver; delete façade wrappers; storage rows (after FS7) all four hosts use pass(); retain manual dirty logic for S4

Amendment: DeviceCore pass and effect protocol

This amendment supersedes sections 2.3 and 2.5 for the host protocol. It also replaces the related parts of slices S2 and S6.

The clean-sheet review in #1433 found that a generated HostCommand table would keep the wrong ownership boundary.

HostCommand and HostEvent are not the final protocol. They are temporary compatibility types.

Revised pass contract

The final pass entry point belongs to DeviceCore.

let plan = device.pass(PassInputs {
    now,
    gestures,
    sensors,
    outcomes,
    external_facts,
});

The pass consumes outcomes before it applies new intents. This order lets DeviceCore reject stale results before it starts new work.

PassPlan must contain render work, the next wake time, reader needs, derived data needs, and named bounded effect slots.

Hold cancellation stays outside PassPlan. App::take_hold_cancel() remains a direct one-shot seam for the high-priority input plane.

The platform cannot call apply_event or a set_* feeder during the pass. It returns typed outcomes on a later pass.

Revised protocol design

S2 must not generate more HostCommand pending slots.

S2 now adds shared vocabulary and per-domain types from #1433:

  • OperationToken and Capability.
  • Named external-fact fields.
  • One intent enum for each domain.
  • One effect enum for each domain.
  • One outcome enum for each domain.
  • Named bounded effect and outcome slots.

Each domain owns its pending state. One global table must not own all domain policy.

A compatibility table can still generate old HostCommand drains during migration. This table must contain translation only.

CoreMode boundary

CoreMode owns admission for heavy work. It also gives the UI one visible product mode.

The board arena remains a physical resource. The board executor maps an admitted effect to an arena claim.

TransferStarted and TransferEnded are external facts. A link task does not change CoreMode directly.

Revised slice order

Complete S1 and S3 during the foundation and DeviceCore gates in #1448.

Complete DC7 #1440 before the revised S2.

Complete the revised S2 before S5.

Complete S5 before the final DeviceCore pass in S6.

Complete S6 before S4. Then complete S4 at the final pass boundary.

Complete S4 before the platform gate in #1448 removes HostLoop and HostPass policy.

Acceptance additions

  • DeviceCore owns the final pass.
  • The pass consumes typed outcomes and external facts.
  • The pass returns bounded primitive effects.
  • No new product feature adds a HostCommand lifecycle.
  • CoreMode admits effects but does not execute hardware work.
  • The host and board can run the same DeviceCore transition tests.
  • Old host types remain only behind a compatibility adapter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:appAffects the obc-app areaepicTracking issue spanning multiple sub-issuesfirmwareOn-device firmware / board bring-upmaintainabilityReadability, structure, duplicationmcu-budgetMCU CPU or RAM budget / on-device performance

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions