ADR-0014: UI Stack — UI Toolkit Screens, Settings Service, Panel Manager
Status
Accepted
Accepted 2026-09-01 (
/architecture-review2026-09-01 — findings F1 and E17 applied; see Amendment History)
Date
2026-09-01
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) |
| Domain | UI |
| Knowledge Risk | HIGH (VERSION.md project risk) / MEDIUM for this domain (architecture.md §Engine Knowledge Gap: "USS strict parsing blocks import on invalid selectors") |
| References Consulted | docs/engine-reference/unity/VERSION.md, modules/ui.md (⚠️ dated 2026-02-13 but cites 6000.0 doc URLs — treated as directional only, not authoritative on 6.3), breaking-changes.md, deprecated-apis.md; live 6.3 docs (below) |
| Post-Cutoff APIs Used | PanelSettings.scaleMode / .scale / .referenceResolution / .screenMatchMode / .sortingOrder; UI Toolkit built-in runtime event system (no EventSystem); strict USS import |
| Verification Required | V1 PanelSettings.scale and .scaleMode are settable at runtime and re-layout live (property list is doc-attested; runtime settability is not) · V2 USS lint gate fails a CI build on an invalid selector · V3 AccessibilityNode.invoked + non-flags AccessibilityRole compile on 6.3 · V4 Awaitable-wrapped PerformInteractiveRebinding (ADR-0007 Verification 2, deferred here) · V5 150 % UI scale on a 1280×720 window does not clip any approved screen · V6 unscaled world text against 150 %-scaled panels does not read as broken (§3) |
Live documentation verified (2026-09-01)
Two claims in this ADR are post-cutoff and were verified against live 6.3 docs rather than training data:
- Panel Settings properties —
Scale Mode(Constant Pixel Size·Constant Physical Size·Scale with Screen Size),Reference Resolution,Screen Match Mode,Reference DPI,Fallback DPI, and a separate uniformScale("Multiply element sizes by this value. Must be greater than 0"). Source: Panel Settings properties reference, PanelSettings scripting API (6000.3). This is what makes §3 possible: resolution-independence and the accessibility scale knob are two different properties, so they compose instead of fighting. - Runtime event system — "When you enter Play mode, UI Toolkit creates a default event system that is not part of any scene."
PanelEventHandler+PanelRaycasterare created only when a uGUIEventSystemexists, and "the presence of these components deactivates the UI Toolkit's automated, built-in input processing." Source: UI Toolkit runtime event system. This corrects review finding E2 (§7).
6.3 hazards this ADR is built around
- Strict USS parser — invalid selectors/syntax now block asset import (previously tolerated). USS is a build-breaking surface, not a cosmetic one → §8 lint gate.
[SerializeField]is fields-only — a compile-time error on properties/methods/types. Binds §1's view-model style.- Accessibility API changed —
AccessibilityRoleflags→standard enum (one role per node),int→byte;AccessibilityNode.selected→.invoked. Any screen-reader work uses the new names (V3).
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0003 (assembly boundaries — Driftlands.Presentation is the only UI-referencing assembly), ADR-0004 (atomic writer reused by the settings service), ADR-0006 (ui.json joins the manifest — amended here), ADR-0007 (input maps, IRebindService — amended here) |
| Enables | Every /ux-design spec's implementation stories: inventory, crafting, build-mode, hud, settings, pause, title-screen (all APPROVED, all blocked on this) |
| Blocks | The first UI story of any epic; the Settings screen story specifically (ADR-0007 defers its Awaitable rebind wrap here) |
| Ordering Note | Does not depend on ADR-0008 (netcode wire). title-screen.md deliberately split mechanism from payload so the join flow's UI is parity-safe whatever ADR-0008 rules. ADR-0013 (tide-tint) is independent — different pipeline, no shared surface. |
Context
Problem Statement
Seven UX specs are APPROVED (inventory, crafting, build-mode, hud, settings, pause, title-screen) against a shared interaction-pattern library at v0.19. Between them they name a settings service, a panel manager, a UI-scale mechanism, a reason-string contract and two schema-negative tests — none of which any ADR owns. traceability-index.md carries TR-ui-001/002/003 as ❌ and TR-ui-004 as ⚠️, all pointing here. No UI story can start until this is Accepted.
The design work is done. This ADR is deliberately not a redesign: where a spec or pattern already states a contract, this ADR ratifies it as a testable interface and names the owner. Where the specs left an explicit question for ADR-0014 (settings OQ 4/5/6/10, title-screen OQ 10, a11y OQ 1), it rules.
Constraints
- Assembly purity (ADR-0003): UI lives only in
Driftlands.Presentation.upward_assembly_referenceis a registered forbidden pattern — Rules/Data/Tide may never see aVisualElement. - Input (ADR-0007):
input_system_outside_adapterandscheme_gating_gameplayare forbidden. The UI readsInputFrame;InputSchemeis presentation-only (glyphs), never a gameplay branch. - No hardcoded values (ADR-0006):
hardcoded_gameplay_valueis forbidden → tuning goes inui.json;silent_default_on_missing_keyis forbidden → fail-loud validation. - Save discipline (ADR-0004):
quit_without_flushis forbidden; settings are per machine and must never enter the world save. - Strict USS (6.3): an invalid selector blocks import — a broken stylesheet is a broken build.
- Frame budget: 16.6 ms total, < 100 draw calls. UI is not allocated a per-frame slice because it must not need one (§9).
Requirements
- TR-ui-001 — closed screen-space HUD list; everything else diegetic; UI Toolkit screens + world-space flyouts.
- TR-ui-002 — co-op affordances at MVP: edge ally indicators, storage who-took-what, world-space ping.
- TR-ui-003 — per-cause reason strings with precedence (first failing gate), stability, and minimum-duration / snap hysteresis.
- TR-ui-004 — aggregation ban (schema-negative); explicit gamepad nav order + visible focus; no hover-only; ≥ 4.5:1 contrast; no input gated by animation; rebinding surface in Settings.
- TR-combat-013 — own + party HP, Downed ally edge indicator, host-confirmed world-space damage numbers, no enemy HP bars.
- Every approved spec's Data Requirements table must be satisfiable by the interfaces defined here.
Decision
1. Framework and topology
UI Toolkit for all screen-space UI (confirming architecture.md's standing ruling; the ui-hud quick-spec deferred the tech choice, this ADR closes it). Sprites — not UGUI — for all world-space UI.
- One
UIDocumentper screen, each a.uxml+ one screen.uss, plus one projecttheme.ussholding the art-bible tokens (§7.3 type ramp, palette, focus ring). Screens:hud,inventory,crafting,build-mode-strip,settings,pause,title-screen. - One deliberate exception — the fatal-config screen (ADR-0006 §5). It is inline-styled with no USS and no theme, by design: 6.3's strict USS parsing must not be able to break the screen whose job is to report breakage (ADR-0006 Verification 4). It is therefore excluded from
theme.uss, from the V2 lint gate's scope, and from the panel manager (it renders when no game data exists, before any of this is available). Do not "fix" it by giving it the theme. - Two panels, two
PanelSettingsassets (sortingOrderascending):HudPanel(0) — always-on, non-interactive, never takes focus;ScreenPanel(10) — modal screens, one at a time. - World-space UI is sprite-based: damage flyouts, ally edge indicators, name tags, the interact ring prompt, the ghost reason label, the ping marker. They are
SpriteRenderer/TextMeshPro(the mesh component — neverTextMeshProUGUI, which requires aCanvas) objects in the 2D scene, sorted with the DriftLayer sprites viasortingLayerName/sortingOrder, and are therefore excluded fromuiScale(§3) and from the tide tint by ADR-0013's taxonomy (TR-tint-002excludes character/UI sprites by construction). - No uGUI
EventSystemanywhere in any scene. This is load-bearing, not tidiness: anEventSystemcauses UI Toolkit to spawnPanelEventHandler/PanelRaycaster, whose presence deactivates the built-in input path this project relies on (verified above). Enforced by a PlayMode test (§10). - View-model style: presenters hold plain fields;
[SerializeField]appears on fields only (6.3 compile error otherwise). No reflection binding of gameplay types (ADR-0004'sreflection_serialize_gameplay_typesban applies by analogy — UI reads views, never serialises them).
2. The settings service
A new Foundation-adjacent service in Driftlands.Runtime, exposed to Presentation as ISettingsService. It owns settings.json and nothing else — it is not a general key-value store and no gameplay system reads it.
File: persistentDataPath/Driftlands/settings/settings.json — the same folder as ADR-0007's bindings.json, per machine, written with ADR-0004's atomic writer (temp + File.Replace) without its rolling backups and without F_FULLFSYNC. Rationale (inherited from ADR-0007 §4, and stated in settings.md): a lost settings change is an annoyance, not data loss, so it does not deserve the durability budget the world save gets.
Schema — 14 keys, all present, no partial writes (settings OQ 4 closed; the three provisional Display rows are KEPT, settings OQ 5 closed):
| Key | Type | Default | Range / values | Applied by |
|---|---|---|---|---|
windowMode | enum | Borderless | Fullscreen · Borderless · Windowed | Screen.fullScreenMode |
resolution | {w,h} | native | current monitor's mode list | Screen.SetResolution |
vsync | bool | true | — | QualitySettings.vSyncCount 0/1 |
gamma | float | 1.0 | 0.8–1.4 | tint/post material global |
hudOpacity | float | 1.0 | 0.5–1.0, step 0.05 | HUD presenter, element-scoped (§4) |
musicVolume | float | 1.0 | 0–1 | audio mixer |
sfxVolume | float | 1.0 | 0–1 | audio mixer |
uiVolume | float | 1.0 | 0–1 | audio mixer |
monoAudio | bool | false | — | audio mixer |
uiScale | float | 1.0 | 1.0 · 1.25 · 1.5 | PanelSettings.scale (§3) |
reducedMotion | bool | false | — | transition suppression (§6) |
flashReduction | bool | false | — | feedback presenters |
reviveToggleHold | bool | false | — | combat's local revive handling |
photosensitivityNoticeShown | bool | false | — | title screen — the one bit that screen writes directly (title-screen.md Z5 / Actions / Data) |
Failure behaviour (mirrors ADR-0007 §4, so the two settings files behave identically): missing file → defaults, silent (first run is not an error). Malformed JSON → defaults + loud log + file renamed settings.bad.json + one non-modal notice on the Settings screen. A key present but out of range → clamped, logged by name; a key absent from an otherwise-valid file → that key's default, logged by name. This is deliberately not silent_default_on_missing_key (ADR-0006's ban): the default is applied and named in the log, because a settings file is player-editable and a silent correction is indistinguishable from the setting not working.
Write policy: written per change (each control commits on release/confirm, not per slider tick — pattern #25), debounced 250 ms, and flushed on screen close and on quit. Settings writes are not on the host commit rail and never touch HostCommitQueue — save_io_inside_drain does not apply because this is not save I/O, but the same principle holds: no file write inside a tick.
3. UI scale (a11y OQ 1 / settings OQ 6 — CLOSED)
The two concerns are separate PanelSettings properties, which is what makes this clean:
- Resolution independence:
scaleMode = Scale with Screen Size,referenceResolution = 1920×1080,screenMatchMode = Match Width Or Heightat 0.5. This is the layout baseline every approved wireframe was measured against. - The accessibility knob:
uiScale(1.0 / 1.25 / 1.5) drivesPanelSettings.scale— the separate uniform multiplier — on both panels. It composes with the above rather than overriding it, so a 125 % player at 2560×1440 gets 125 % of a correctly-scaled layout.
World-space text does NOT scale (ruled 2026-09-01): flyouts, name tags, the interact ring prompt and the ghost reason label keep their world size. They are sprites anchored to world objects (§1); scaling them at 150 % would occlude the combat read the art bible's silhouette rule depends on, and no spec has authored overlap rules for that case.
This is a real reduction in the accessibility commitment, and it is stated as one. The a11y doc's own "Minimum text size — HUD" row explicitly counts world-space flyouts as text subject to the size floor ("Damage / yield flyouts are world-space Medium digits"), so this ruling does not merely leave a gap the doc never claimed — it adds a new entry to that doc's Known Intentional Limitations. Two consequences follow and are accepted:
- The 100 % floor becomes load-bearing for world text. Medium 16–18 px with the 2 px ink outline must hold unaided at TV distance, because the scale option will never help it.
- A separate world-text scale is Tier 1, explicitly — not "maybe later". If the friend-group playtest surfaces flyout legibility complaints, that is the fix, and it needs the overlap rules this ADR declined to invent.
V6 covers the near-term risk cheaply: a screenshot check at uiScale = 1.5 confirming unscaled world text against scaled panels does not read as broken.
Layout obligation: every approved screen must survive 1.5 with no clipping and no overlap. settings.md, pause.md and title-screen.md already state per-zone clearance budgets measured at both ends; V5 turns that into a test at 1280×720 (the worst realistic case: smallest supported window × largest scale).
4. HUD opacity is element-scoped
hudOpacity applies to the SHOULD-KEEP tier only — hotbar, party rows, carry meter, and the dash ring when not Downed. Exempt and always 100 %: own HP pips, the bleed-out arc, ally + Downed edge indicators (Z5/Z7), the banner, and the tide glint (Z6). The slider must never dim your own health or hide a downed friend.
This is a presenter-level rule, not a panel-level opacity — a panel-wide alpha would dim the exempt elements too. The HUD presenter applies it per element group; a USS class (.hud-dimmable) marks the eligible set so the exemption is visible in the markup and testable (§10).
5. The panel manager
One IPanelManager in Presentation owns which screen is open and what the input maps are while it is open. Every approved spec's Entry/Exit table resolves to a call on it.
- One modal screen at a time. Opening a screen while another is open closes the first (the specs never stack);
pause.md's "Menu includes pause" clause andsettings.md's "Back returns to the opener" are implemented as a single-entry opener stack (depth ≤ 2: title→settings, pause→settings). - Map states — the manager is the only caller that enables/disables maps, keeping ADR-0007's
input_system_outside_adapterban intact (it asks the adapter; it does not touchInputSystemitself):
| State | Player |
Build |
UI |
Notes |
|---|---|---|---|---|
| Gameplay, no panel | enabled | — | disabled | ADR-0007 §3 rule (c) |
| Build mode | enabled (routed) | enabled | disabled | BuildModeActive |
| Modal screen open | suppressed, except Menu + Inventory edges, which survive (ADR-0007 §3) | disabled | enabled | UiCaptured = true, set from an Update after −1000 → capture applies from the next frame (ADR-0007 §3) |
| Title screen | present, only Menu consumed | disabled | enabled standing | new state — title-screen OQ 10, closed here |
MapState is derived, and Open == null is not enough to derive it: no panel open means Gameplay in the world but Title at the title screen. The manager therefore takes an explicit UiContext (Title | World), set once when the scene loads — named here so two implementers do not invent two different flags.
The title-screen state is the one ADR-0007 §3 does not enumerate: rule (c) ties UI to open panels and enables Player at boot, but the title screen needs UI enabled with no panel and a live Menu read for the 3-second reset floor (ADR-0007 §4's recovery floor). This is an ADR-0007 §3 clarification, not a redesign — the amendment is recorded in §11.
UiCapturedremains the adapter's flag (ADR-0007); the panel manager sets it and gameplay consumers already honour it. No gameplay system learns which screen is open — that would bescheme_gating_gameplay's sibling and is equally banned.
6. Reason strings (TR-ui-003)
Pattern #6's contract is ratified verbatim as a testable pure function in Driftlands.Rules (not Presentation — it is a formula, and formula_in_runtime_assembly is forbidden):
ReasonSelector.Step(in ReasonState prev, int firstFailingGate, long nowMs,
int minDisplayMs, int switchFrames) → ReasonState
The function carries all of its own state in ReasonState (ShownGate, ShownAtMs, CandidateGate, CandidateFrames) and returns a new one — the caller stores the struct and passes it back next frame, exactly as ADR-0007's AimResolver.Resolve(prev, …) does. The consecutive-frame counter is inside the machine, not held by the presenter: a presenter that owned the counter would be holding a piece of the state machine, which formula_in_runtime_assembly forbids and which would make the unit test exercise a step rather than the machine. minDisplayMs / switchFrames stay parameters (fed from ui.json, satisfying hardcoded_gameplay_value).
- Precedence: the first failing gate in the owner's stated order — BB: reach → bounds → overlap → occupancy → exit → terrain → socket → materials; crafting: station → world → affordability. The order is the owner GDD's, passed in; the selector does not know the domain.
- Stability: a displayed string persists a minimum 250 ms.
- Hysteresis: a change between gates requires the new gate to be first-failing for 2 consecutive frames.
- The visual is never hysteretic — the ghost outline flips solid ↔ dashed truthfully every frame. Hysteresis governs the string only. This distinction is the one most likely to be implemented wrong, so it is a named unit test (§10).
- Content is each GDD's; tone is the ui-hud quick-spec's tone rule (world facts, never scolding); length budget ≤ 32 chars English (L10n HIGH).
Both tuning values live in ui.json (§8) — reasonMinDisplayMs = 250, reasonSwitchFrames = 2 — because pattern #6 marks them provisional, playtest-tuned.
7. Input — ratifying and enforcing the E2 mechanism
Review finding E2 (2026-08-28) found ADR-0007's original PanelEventHandler sentence named the wrong mechanism. That correction was already applied to ADR-0007 on 2026-08-28 — §Post-Cutoff (a) and the §1 UI row both carry the corrected text today. This ADR does not re-correct it; it independently re-verified the mechanism against live 6.3 docs (above), ratifies it, and adds the thing ADR-0007 could not: an enforcing test (§10), since the correct behaviour depends on a negative condition — the absence of an EventSystem — that nothing currently guards.
The ratified mechanism:
UI Toolkit's built-in, scene-independent default runtime event system consumes the project-wide
UImap directly.PanelEventHandlerandPanelRaycasterare created only if a uGUIEventSystemis present, and their presence deactivates the built-in path. Since this project has noEventSystem(§1), they are never created.
Consequences that survive unchanged: no components are needed on any panel, and rebinding never touches the UI map (UI.Cancel is protected, ADR-0007 §4).
What is actually load-bearing (specialist correction, 2026-09-01): UI Toolkit does not match a map literally named UI. It derives events from whatever is assigned at Project Settings → Input System Package → UI Actions, matched by role — Point, Click, Navigate, Submit, Cancel, ScrollWheel, and the tracked-device pair. In this project the two coincide (the project-wide asset's map is named UI and is the assigned UI Actions), which is why the outcome is right — but the fragile things are (a) the UI Actions assignment in Project Settings and (b) the individual action names and types. Renaming the containing map alone would break nothing in UI Toolkit's dispatch path — and that qualifier is load-bearing (architecture review E17): the Input System package's own UI-support documentation states that, for the default setup, "the name of the action map (UI), the names of the actions it contains, and their respective Action Types must remain the same" in order to remain compatible with UI Toolkit. That guidance describes name-based resolution of the stock asset; it is safe to depart from only because this project explicitly assigns the UI Actions reference rather than relying on the default. Do not generalise this into "map names never matter". §10's asset test asserts accordingly.
AimStick rebindable / AimPointer not — confirmed (settings OQ 10 closed): a pointer has nothing to rebind to, while a left-handed player swapping sticks is a real need. The Settings screen shows an AimStick row and no AimPointer row.
Awaitable rebind wrap (ADR-0007's deferred Verification 2, owner = this ADR): IRebindService.RebindAsync wraps PerformInteractiveRebinding with AwaitableCompletionSource, cancelled by the screen's CancellationToken on close, on the listen timeout, and on Menu.
Disposal is part of the contract, not an implementation detail. RebindingOperation is IDisposable and holds unmanaged memory — Unity's own docs warn it must be disposed or it leaks the native heap. The wrapper calls RebindingOperation.Dispose() on all four exit paths — successful completion, cancel, timeout, and screen close — before or immediately after completing the AwaitableCompletionSource. The happy path matters most here: a completed rebind exits via none of the three cancellation triggers, so a wrapper that only disposes on cancel leaks once per successful rebind, and settings.md's reset-to-defaults flow can drive many in one session. V4 asserts no native-heap leak on every path, not just the cancelled ones.
8. Data and the USS gate
ui.json — a new file in ADR-0006's manifest (amendment, §11). It carries every UI tuning value so none are hardcoded (hardcoded_gameplay_value): uiScaleSteps [1.0, 1.25, 1.5], hudOpacityRange [0.5, 1.0], reasonMinDisplayMs 250, reasonSwitchFrames 2, glintBandPx, pipQuotientMax 8, plus hud.md's stage-9 rows. Validated at boot in ADR-0006's stage 9, ranges as warnings (a UI value out of range is clamped and logged, never a boot failure — the game must still start).
ui.json is shipped data (assets/data/ → StreamingAssets/), read-only at runtime. settings.json is runtime-written and therefore correctly absent from ADR-0006's manifest — the two must not be confused (settings OQ 4).
USS lint gate (V2) — because 6.3's strict parser makes an invalid selector a build failure, the failure must surface in CI, not on a developer's next import. An editor test asserts every .uss under Assets/UI/ imports without error; it runs in the existing GitHub Actions unity-test-runner job. This is cheap insurance against a class of breakage that is invisible until someone reimports.
9. Performance stance
The stance splits in two, because the two halves have genuinely different cost models.
(a) UI Toolkit panels — zero allocated slice. The HUD panel updates only on change, never per frame: presenters subscribe to the events the systems already raise (SwingLanded, HealthChanged, tide floats via the tint driver's existing per-frame read). UI Toolkit is retained-mode, so an unchanged panel costs no rebuild. hudOpacity (§4) is applied on SettingsChanged only, never per frame, over a cached .hud-dimmable query result — the query runs once per screen build.
No per-frame slice is allocated to the panels, and this is a deliberate invariant, not an omission: if the panels ever need a measurable slice of 16.6 ms, something is being rebuilt that should not be. Registered as system: ui-panels, budget_ms: 0.0 with that note (the land-lifecycle row is the precedent for a zero-with-rationale entry) so a future ADR cannot quietly take one.
(b) World-space UI — a real, bounded slice. Flyouts, ally edge indicators, the ping marker and the interact ring are SpriteRenderer/TMP objects that move every frame with the world objects they anchor to. The retained-mode argument does not apply to them. hud.md already states the number: ≤ 0.2 ms/frame with 4 party rows, 8 flyouts and 4 edge glyphs on the reference machine (its performance AC). That is this ADR's registered budget — system: ui-worldspace, budget_ms: 0.2 — not a new claim.
The population is provably bounded, which is what makes the number safe: flyoutCap = 8 (ui.json, culled oldest-first), edge indicators ≤ party size, one ring, one ping. Nothing here is unbounded.
Draw calls. The project budget is < 100. World-space UI sprites are sorted alongside the DriftLayer sprites (sortingLayerName/sortingOrder) but they do not share that atlas: ADR-0005 §2 keeps the UI atlas separate and non-addressable, while every DriftLayer atlas is packed inside a per-template Land_<id> Addressables group and is "referenced by exactly one addressable (this prefab)" — the invariant that keeps the setup free of the duplicate-texture pitfall. Putting UI sprites in a land atlas would both break that invariant and unload the flyouts at LandTornDown. So the honest cost is one batch for the UI atlas plus one for the shared TMP font-atlas material — about +2 draw calls, not zero — a fixed cost independent of the flyout/indicator population, which is what the bounded population above actually buys. Against a < 100 budget with a measured total near 40, this is immaterial, but it is stated as a real cost rather than claimed away. (ADR-0013 owns tint and BB-overlay draw-call cost; flyout/indicator cost is owned here. ADR-0013 §8 makes the same point from the other side: "Batching with 'the atlas' is only true of the right one.")
Frame rate and vsync. Application.targetFrameRate = 60 is pinned regardless of the vsync setting — the 16.6 ms budget every other system is designed against must not become 6.9 ms because a player on a 144 Hz monitor turned vsync off. vsync (§2) selects QualitySettings.vSyncCount 0/1 for tearing behaviour only; it never changes the frame target.
The one genuinely per-frame rules cost is the ghost reason label in build mode. ReasonSelector.Step is pure, allocation-free (a readonly record struct in, one out), and runs once per frame — the same class of cost as ADR-0007's AimResolver.
Architecture Diagram
┌─ Driftlands.Presentation ────────────────────────────────────────────────┐
│ │
│ HudPanel (PanelSettings, sortingOrder 0) ScreenPanel (order 10) │
│ └ hud.uxml ── HudPresenter └ one of: │
│ .hud-dimmable ← hudOpacity (§4) inventory · crafting │
│ exempt: HP pips, bleed arc, Z5/Z6/Z7 settings · pause │
│ title-screen │
│ both: scale ← uiScale (§3) │
│ │
│ IPanelManager ── owns open screen + map state (§5) ── opener stack ≤ 2 │
│ │ │
│ World-space sprites (NOT UI Toolkit, NOT uiScale-scaled): │
│ flyouts · ally edge indicators · name tags · ring prompt · ping │
└──────────┬───────────────────────────────────────────────────────────────┘
│ reads views only (never writes game state)
│ asks services to change settings
┌──────────▼─ Driftlands.Runtime ──────────────────────────────────────────┐
│ ISettingsService → settings.json (ADR-0004 atomic writer, no backups) │
│ IRebindService → bindings.json (ADR-0007; Awaitable wrap here §7) │
│ InputAdapter → InputFrame (UiCaptured set by IPanelManager) │
└──────────┬───────────────────────────────────────────────────────────────┘
│
┌──────────▼─ Driftlands.Rules (pure, no UnityEngine) ─────────────────────┐
│ ReasonSelector.Step(prev, prevAtMs, firstFailingGate, nowMs) → shown │
└──────────┬───────────────────────────────────────────────────────────────┘
┌──────────▼─ Driftlands.Data ─────────────────────────────────────────────┐
│ UiConfig ← ui.json (ADR-0006 stage 9, ranges as warnings) │
└──────────────────────────────────────────────────────────────────────────┘
NO uGUI EventSystem anywhere → UI Toolkit's built-in runtime event system
consumes the project-wide `UI` map directly (§7).
Key Interfaces
// Driftlands.Runtime — settings
public interface ISettingsService {
SettingsSnapshot Current { get; } // immutable record, 14 fields
void Update(Func<SettingsSnapshot, SettingsSnapshot> edit); // clamps + logs by name; debounced write
Awaitable FlushAsync(); // on screen close and on quit
event Action<SettingsSnapshot> SettingsChanged;
SettingsLoadResult LastLoad { get; } // Ok | DefaultsFresh | DefaultsRecovered(badPath)
}
// Driftlands.Presentation — panels
public interface IPanelManager {
ScreenId? Open { get; }
void OpenScreen(ScreenId id, ScreenId? opener = null); // closes any current screen
void Back(); // returns to opener, else closes
void CloseAll();
UiMapState MapState { get; } // Gameplay | Build | Modal | Title
event Action<ScreenId?> OpenChanged;
}
// Driftlands.Rules — pure, allocation-free, unit-tested
public readonly record struct ReasonState(
int ShownGate, long ShownAtMs, int CandidateGate, int CandidateFrames);
public static class ReasonSelector {
// The machine owns its own frame counter; the caller only stores the struct.
public static ReasonState Step(in ReasonState prev, int firstFailingGate,
long nowMs, int minDisplayMs, int switchFrames);
}
SettingsSnapshot is an immutable record and Update takes a pure edit function, so every change produces a new snapshot and raises SettingsChanged; nothing mutates in place (project immutability rule). A generic Set<T>(SettingKey, T) was rejected: it cannot be compile-checked (Set(SettingKey.UiScale, true) would compile), and every other interface in this project is precisely typed.
Alternatives Considered
Alternative A: UGUI (Canvas) for all UI
- Description: Canvas +
CanvasScaler+ TextMeshPro for screens and world-space alike. - Pros: Mature, heavily documented, most Unity answers online target it; world-space canvases are a first-class feature.
- Cons: Requires an
EventSystem, which — verified above — deactivates UI Toolkit's built-in input path, so the two cannot be mixed casually. Immediate-cost layout rebuilds on change.architecture.mdalready ruled UI Toolkit andDriftlands.Presentationis declared against it. - Rejection Reason: Contradicts a standing architecture ruling (TR-ui-001) with no new evidence to justify reopening it, and the retained-mode cost model in §9 is the one this project's "update only on change" HUD wants.
Alternative B: UI Toolkit screens + UGUI world-space canvases for flyouts
- Description: The split
architecture.mdrow 14 explicitly left open ("world-space flyouts (UGUI or sprites)"). - Pros: World-space canvases handle text layout, wrapping and billboarding for free; richer than a sprite.
- Cons: Pulls in an
EventSystem(the input-path problem above) or requires carefully keeping the canvases non-interactive forever — a discipline with no compile-time enforcement. Adds a third UI system to a 2D game whose world text is short, non-interactive, and already sorted alongside DriftLayer sprites. - Rejection Reason: Chose sprites. All world-space UI in the approved specs is non-interactive text or a marker; none needs canvas layout. Sprites keep the sorting model uniform with the art bible's layer taxonomy and avoid the EventSystem hazard entirely. This closes the open half of row 14.
Alternative C: uiScale via referenceResolution manipulation
- Description: Implement the 100/125/150 % knob by dividing
referenceResolution(e.g. 1920×1080 → 1536×864 for 125 %). - Pros: One property instead of two; superficially equivalent.
- Cons: Conflates the accessibility knob with the layout baseline. Every wireframe measurement in the approved specs is stated against 1920×1080; moving that number invalidates the clearance tables the specs spent two review passes measuring, and
screenMatchModethen interacts with the scale factor in a way that differs per aspect ratio. - Rejection Reason:
PanelSettings.scaleexists precisely for this (verified) and keeps the baseline fixed, so the specs' measured tables stay valid.
Alternative D: One panel for HUD and screens
- Description: A single
PanelSettings+UIDocument, screens toggled as hidden subtrees. - Pros: One asset; trivially consistent scale.
- Cons: HUD and screens have different interactivity (HUD never takes focus), different opacity rules (§4), and would share a focus ring context — the gamepad focus order TR-ui-004 requires would have to be rebuilt on every screen open.
- Rejection Reason: Two panels with distinct
sortingOrdercost one extra asset and remove a whole class of focus bugs.
Alternative E: Settings inside the world save
- Description: Persist the 13 values in ADR-0004's save file.
- Pros: One file; settings travel with the world.
- Cons: Settings are per machine (a friend's gamma is not your gamma), and ADR-0004's save schema defines no settings table. Would also make settings subject to the reconcile pipeline, which is meaningless for them.
- Rejection Reason: Explicitly forbidden by ADR-0007 §4's schema-negative assertion. This ADR adds the enforcing test that ADR-0004 never had (§10).
Consequences
Positive
- Five of the seven APPROVED UX specs become fully implementable, and the other two (
title-screen,hud) become implementable except for the surfaces blocked on other ADRs' amendments — enumerated in "Blocked on, not owned here" below rather than discovered at story time. - TR-ui-001..004 and TR-combat-013 close;
traceability-index.md's three ❌ and one ⚠️ clear. - The E2 mechanism (corrected in ADR-0007 on 2026-08-28) is independently re-verified against live 6.3 docs and, for the first time, enforced by a test — the no-
EventSystemcondition it depends on was previously unguarded. - Two open questions the a11y doc had blocked on ("before the first UI story") are answered with verified, not assumed, engine behaviour.
- The USS lint gate converts a 6.3 build-breaking class into a CI failure with a name on it.
ReasonSelectorinRulesmeans the hysteresis contract is unit-testable without a scene — the same purity dividend ADR-0003 bought everywhere else.
Negative
- World-space text does not honour
uiScale— a real reduction against a commitment the a11y doc had already made (its text-size row counts flyouts). Accepted deliberately (§3), recorded as a new Known Intentional Limitation rather than left implicit, with the 100 % floor now load-bearing for those elements and a separate world-text scale named as Tier 1. - Two
PanelSettingsassets and two panels to keep in sync (scale is applied to both; a test asserts it). - The Display rows (
windowMode/resolution/vsync) are kept without a GDD mandating them. This is not a weak position: they are platform-shell settings, not gameplay settings, so no GDD has jurisdiction over them and none ever will — their absence would be a PC shipping defect. The TD owns them precisely because no designer does. ui.jsonadds a ninth file to ADR-0006's boot validation.
Risks
PanelSettings.scalemay not re-layout live at runtime (V1 — property list is doc-attested, runtime settability is not). Mitigation: if it needs a panel refresh, the settings screen already closes and reopens on apply for the Display rows; the same path serves scale. If it cannot change at runtime at all,uiScaleapplies at boot with a "restart to apply" notice — the fallback the Display rows already use.- Strict USS bites during authoring rather than in CI, blocking a developer mid-task. Mitigation: V2's test runs locally too; the error names the selector.
- 150 % clipping on a small window (V5). Mitigation: every approved spec already carries measured clearance tables at both ends; this verifies rather than discovers.
- **The UI Actions assignment and the individual action names/types are load-bearing — UI Toolkit binds them by role, so reassigning the UI Actions reference in Project Settings, or renaming/retyping
Navigate/Submit/Cancel/Point/Click/ScrollWheel, silently breaks navigation. The containing map's own name is not** load-bearing. Mitigation: ADR-0007's asset test already asserts the action set; §10 adds the assignment check and the no-EventSystemhalf. RebindingOperationnative-heap leak if disposal is missed on the happy path (§7). Mitigation: disposal is stated as part of the interface contract, and V4 asserts all four paths.Reference DPI/Fallback DPIare a third input to the effective scale and are deliberately left at their defaults — this project targets desktop PC where per-monitor OS scaling already applies, and adding a DPI term would compound withuiScaleunpredictably across friends' monitors. Noted so a future reader knows they were considered, not missed. V5's 1280×720 × 1.5 case covers the practical worst case.- The HUD panel has no natural "reopen" moment. If V1 resolves to the close-and-reopen fallback,
ScreenPanelgets that for free butHudPanelis always-on and never closed. Mitigation: the fallback path must rebuild or re-assign the HUD panel'sPanelSettingsexplicitly onSettingsChanged; if even that fails,uiScalefalls back to boot-time application with the "restart to apply" notice the Display rows already use. - Accessibility API churn (
AccessibilityRole,.invoked) if screen-reader support is attempted later. Mitigation: V3 compiles the new surface now; no screen-reader work is in MVP scope.
GDD Requirements Addressed
| GDD / Spec | Requirement | How This ADR Addresses It |
|---|---|---|
design/quick-specs/ui-hud-2026-08-27.md Rules 1–2 | Closed screen-space HUD list; everything else diegetic | §1 topology: HudPanel renders the closed list; all other state is world-space sprites (TR-ui-001) |
| ui-hud Rule 3 | Co-op affordances at MVP: edge ally indicators, storage who-took-what, world-space ping | §1 world-space sprite set; §4 exempts ally/Downed indicators from hudOpacity so they can never be dimmed away (TR-ui-002) |
| ui-hud Rule 4 / BB UI Requirements | Per-cause reason strings, precedence, stability, snap hysteresis | §6 ReasonSelector.Step in Rules — 250 ms floor, 2-frame switch, string-only hysteresis (TR-ui-003) |
| ui-hud Rules 5–6 | Aggregation ban; gamepad nav + visible focus; no hover-only; ≥ 4.5:1; no animation-gated input | §10 schema-negative aggregation test; §1 focus ring in theme.uss; §5 modal focus context (TR-ui-004) |
design/gdd/combat-system.md UI Requirements | Own + party HP, Downed ally edge indicator, host-confirmed damage numbers, no enemy HP bars | §1 flyouts are world-space sprites driven by host-confirmed events only; §4 keeps own HP and the bleed-out arc at full opacity (TR-combat-013) |
design/ux/settings.md Data Requirements | 13 settings, settings.json, atomic writer, settings.bad.json | §2 in full — schema, path, writer, failure behaviour (OQ 4, 5, 6, 10 closed) |
design/ux/hud.md Tuning Knobs | All values in assets/data/ui.json; nothing hardcoded | §8 ui.json + ADR-0006 manifest amendment (hud OQ 7) |
design/ux/title-screen.md OQ 10 | UI enabled standing with Player present, only Menu consumed | §5 map-state table — the fourth state, plus the ADR-0007 §3 clarification (§11) |
design/accessibility-requirements.md OQ 1 | UI-scale mechanism; does world text scale? | §3 — PanelSettings.scale for panels; world text stays world-sized (ruled, recorded as a limitation) |
design/ux/interaction-patterns.md #6, #25 | Reason-string contract; slider commit semantics | §6 ratifies #6 verbatim; §2 write policy implements #25's commit-on-release |
Performance Implications
- CPU: panels rebuild only on change (retained mode) — 0.0 ms allocated. World-space UI ≤ 0.2 ms/frame at the capped population (
hud.md's performance AC). One pureReasonSelector.Stepper frame in build mode. No allocation per frame. - Draw calls: world-space UI draws from the separate non-addressable UI atlas (ADR-0005 §2), not the DriftLayer/land atlases; TMP shares one font atlas material. ≈ +2 draw calls, fixed — no per-element draw call, but not zero (§9).
- Memory: two panels, seven
UIDocuments, one theme stylesheet — kilobytes. Screens are loaded with their scene, not Addressable (they are not per-land content, so ADR-0005's budget is untouched). - Load Time:
settings.json+ui.jsonread once at boot. - Network: none. UI is entirely client-local; no UI state is replicated. Settings never enter the world save or any RPC.
Migration Plan
No production code exists yet. Order:
- V1 spike first (a day, not a phase): confirm whether
PanelSettings.scalere-lays-out live at runtime. It is cheap, it is doc-unattested, and it decides whether §3's player-facing copy reads "applies immediately" or "applies on reopen" — which is a UX-copy change insettings.md, not just an implementation detail. Running it last would mean writing the copy twice. theme.uss+ twoPanelSettingsassets + the USS lint editor test (V2 green before any screen is authored — the gate must exist before the surface it guards).UiConfiginData+ui.json+ ADR-0006 stage-9 rows (ADR-0006 amendment applied in this change).ReasonSelectorinRules+ its unit tests (pure, no scene — can land independently of everything else).ISettingsService+settings.jsonon ADR-0004's writer + theSchema_*settings-not-in-save test.IPanelManager+ the four map states + the no-EventSystemPlayMode test (ADR-0007 §3 clarification applied in this change).IRebindService.RebindAsyncAwaitablewrap (ADR-0007 Verification 2, V4).- Screens in
/ux-designqueue order; each story citesdesign/ux/<screen>.md, never a GDD. architecture.md§4 + registry sync in the same change.
Validation Criteria
- UNIT (
ReasonSelector): a string persists ≥ 250 ms; a new first-failing gate replaces it only after 2 consecutive frames; a gate failing for 1 frame then recovering never displaces the shown string; precedence returns the first failing gate in the supplied order. - UNIT (settings): out-of-range value → clamped and logged by name; missing key → default and logged by name; malformed file → defaults +
settings.bad.json; missing file → defaults, no error log. - UNIT (schema-negative, TR-ui-004): no type in
Driftlands.*exposes a per-player aggregate of craft counts, build counts or harvest tallies — the aggregation ban is a compile-visible fact, not a UI convention. - UNIT (schema-negative, settings OQ 4): ADR-0004's save schema declares no settings table and no field named in §2's 14 keys — the enforcing test ADR-0004 never had.
- UNIT (reflection, ADR-0003):
UnityEngine.UIElementsis referenced only fromDriftlands.Presentation;ReasonSelector's assembly references noUnityEngine. - EDITOR (V2): every
.ussunderAssets/UI/imports with zero errors — fails the CI build if not. - PLAYMODE: no
EventSystemexists in any loaded scene, and noPanelEventHandler/PanelRaycasteris ever instantiated (§7's mechanism, enforced). - EDITOR (asset): Project Settings' UI Actions reference resolves to the project-wide asset's UI map, and that map's action names and types equal Unity's reference set. The map's own name is deliberately not asserted — because this project sets the UI Actions reference explicitly, so UI Toolkit resolves by that assignment and by action role, not by a string match on
UI(§7). The assertion is scoped to this project's configuration, not claimed as a general engine fact (review E17). - PLAYMODE (V1): setting
uiScaleto 1.5 changes rendered element size on both panels without a scene reload — or, if it does not, the boot-time fallback + notice path is exercised instead. - PLAYMODE (V5): at 1280×720 and
uiScale = 1.5, every approved screen renders with no clipped text and no overlapping interactive zones. - PLAYMODE (V4): an interactive rebind disposes its
RebindingOperationon all four exit paths — successful completion, cancel, timeout, screen close — leaving no dangling operation and no native-heap leak. The happy path is asserted explicitly, not assumed covered by the cancel cases. - PLAYMODE (§4): with
hudOpacity = 0.5, own HP pips, the bleed-out arc, Z5/Z6/Z7 render at full opacity while.hud-dimmableelements do not. - PLAYMODE (V7, §9 draw calls): measured as a marginal delta (count immediately before vs. after the world-space UI population appears), going from zero flyouts/indicators to the full bounded population — 4 party rows, 8 flyouts (
flyoutCap), 4 edge glyphs, one ring, one ping — adds ≤ 2 draw calls and the delta does not scale with element count. A per-element cost means the UI atlas is fragmented or TMP is not sharing one font-atlas material. Mirrors ADR-0013's V4: measure the delta, never "total < 100", which passes trivially at a total near 40. - COMPILE (V3):
AccessibilityNode.invokedand a non-flagsAccessibilityRolecompile on 6.3.
Blocked On — Not Owned Here
This ADR closes the UI stack. Three surfaces the approved specs describe depend on amendments that belong to other ADRs, and are listed here so the first story does not discover them:
| Surface | Blocked on | Owner | Effect if it does not land |
|---|---|---|---|
World list, Continue's target, world auto-names (title-screen.md OQ 9) | Four ADR-0004 amendments: (a) SaveHeader ReadHeader(worldFolder) — header parse without the load pipeline; (b) createdUtc; (c) lastPlayedUtc; (d) CreateWorld(seed) → worldFolder with a named owner for seed rolling. None exists today. | ADR-0004 | The world list cannot name or sort; Continue has no target. The title screen's other paths (Host, Join, Settings, Quit, reset floor) are unaffected. |
HUD element E10 Ping (hud.md) — and therefore one third of TR-ui-002 | The ADR-0007 Ping amendment (MMB / R3). No Ping action exists in §1's table. | ADR-0007 | hud.md already degrades gracefully: the element simply does not render, no layout or budget change. TR-ui-002 closes on two of three limbs (edge indicators, who-took-what) until this lands. |
Storage who-took-what feed (pattern #13, inventory.md) | The confirmed-InventoryTransfer broadcast payload from ADR-0002's drain — no ADR defines its shape. | ADR-0010 (inventory replication) | The chest panel renders host-confirmed contents without the attribution tags. Tag lifetime is carried in ui.json so only the payload is missing. |
None of these is this ADR's to make: extending the save header is ADR-0004's, binding Ping is ADR-0007's, and the transfer broadcast is inventory replication's. Claiming them here would be exactly the cross-ADR overreach the coordination rules forbid.
Rulings Confirmed (no document changes)
These were explicitly deferred to ADR-0014 by another document, and are confirmed here. Nothing in the source document changes:
| Ruling | Deferred by | Confirmation |
|---|---|---|
AimStick rebindable (left-handed stick swap), AimPointer not | ADR-0007 Amendments (2026-08-31, item 3) — "a UX ruling for ADR-0014 to confirm" | Confirmed. A pointer has nothing to rebind to; the stick swap is a real need. Settings shows an AimStick row, no AimPointer row. |
| Window mode / Resolution / VSync kept | settings.md OQ 5 — "ADR-0014 confirms or removes" | Confirmed kept (§2). |
| UI-scale mechanism | accessibility-requirements.md OQ 1 | Resolved (§3) — this one does amend the a11y doc, see below. |
Applied on Acceptance — ✅ ALL APPLIED 2026-09-01
Historical record. While this ADR was Proposed, the amendments below were described but deliberately not applied — amending Accepted documents on the strength of a Proposed one would strand those edits if
/architecture-reviewreturned this for revision. That deferral ended when this ADR was Accepted on 2026-09-01: all four amendments and every registry stance are now applied and committed (dc7c1b0). The list is kept as the record of what landed, not as outstanding work.
Until this ADR is Accepted, the following statements in it describe an intended future state, not the repository as it stands:
| This ADR says | Target document currently says | Applied when |
|---|---|---|
§8: ui.json is validated at ADR-0006 stage 9 | ADR-0006 stage 9 does not mention ui.json; Configs has no Ui member | Acceptance |
| §5: the title-screen map state is ruled | title-screen.md OQ 10 reads "for ADR-0014's panel manager" (unresolved) | Acceptance |
| §3: the UI-scale mechanism is resolved | accessibility-requirements.md OQ 1 reads "Unresolved" | Acceptance |
| §2/§7: settings OQ 4/5/6/10, hud OQ 7 are closed | Those OQs are still open in their specs | Acceptance |
The registry stances this ADR creates (ui-panels 0.0 ms, ui-worldspace 0.2 ms, the ISettingsService/IPanelManager/ReasonSelector interfaces, and the no-EventSystem forbidden pattern) are likewise not registered yet, per the architecture-decision gate: a Proposed decision does not claim registry ownership.
One registry correction was applied ahead of this ADR because it is independent of it: rebind_policy still recorded "swap on confirm", which ADR-0007's 2026-08-31 amendment superseded with exchange. That row was wrong for anyone reading it today, regardless of ADR-0014's fate.
Documents Amended by This ADR
Status: ✅ applied 2026-09-01. See "Applied on Acceptance" above. Each row records the exact edit that was made when this ADR was Accepted.
| Document | Amendment |
|---|---|
docs/architecture/adr-0007-input-action-map.md | One amendment: §3 gains the title-screen map state — UI enabled standing, Player present with only Menu consumed (a state §3's rule (c) does not enumerate). §4's deferred Verification 2 (Awaitable rebind wrap) is claimed here as its named owner — ADR-0007 already assigns it to ADR-0014, so no text in ADR-0007 changes. |
docs/architecture/adr-0006-game-data-loading-validation.md | §1 file list gains ui.json (uiScaleSteps, hudOpacityRange, reasonMinDisplayMs, reasonSwitchFrames, glintBandPx, pipQuotientMax + hud.md stage-9 rows); §2 step 3 Configs.{…, Ui, …}; stage 9's title gains Ui (Movement / Camera / Input / Inventory / Save / Land / Ui) and the stage gains the rows, ranges as warnings. settings.json is runtime-written and stays out of the manifest. |
design/accessibility-requirements.md | (a) OQ 1 resolved: mechanism = PanelSettings.scale (knob) over Scale with Screen Size @ 1920×1080 (baseline). (b) A new Known Intentional Limitation: world-space text does not scale — with the rationale, the load-bearing 100 % floor, and the Tier 1 world-text scale. (c) The "Minimum text size — HUD" row is annotated to note that its flyout clause is unaffected by uiScale. |
docs/architecture/architecture.md | §4 gains ISettingsService / IPanelManager / ReasonSelector; Required-ADR row 14 marked Accepted on approval; row 14's open half ("UGUI or sprites") resolved to sprites. |
design/ux/settings.md, design/ux/hud.md, design/ux/title-screen.md | OQ closures recorded: settings OQ 4/5/6/10, hud OQ 7, title-screen OQ 10. Specs remain APPROVED — no design changes, only answers. |
Related Decisions
- ADR-0003 (assemblies —
Presentationis the only UI referencer;ReasonSelectorlives inRules) - ADR-0004 (atomic writer reused without backups/
F_FULLFSYNC; this ADR adds the settings-not-in-save test ADR-0004 lacked) - ADR-0006 (amended —
ui.json) - ADR-0007 (amended — E2 correction, title map state,
Awaitablewrap, aim rebinding) - ADR-0013 (tide-tint — world-space UI sprites are excluded from the
DriftLayertaxonomy by construction; no overlap) - ADR-0008 (netcode wire — no dependency:
title-screen.md's mechanism/payload split keeps the join UI parity-safe whatever it rules) design/ux/{inventory,crafting,build-mode,hud,settings,pause,title-screen}.md(all APPROVED),design/ux/interaction-patterns.mdv0.19,design/quick-specs/ui-hud-2026-08-27.md,design/accessibility-requirements.md,design/art/art-bible.md§7
Amendment History
- 2026-09-01, architecture review E17 — UI Actions map-name claim scoped. §7 and §10 asserted that the UI map's own name "is not what UI Toolkit matches on" as a general engine fact. True of UI Toolkit's dispatch (it resolves the Project Settings UI Actions assignment and matches by action role), but the Input System package's UI-support docs make the map's name part of the compatibility contract for the default setup. Both places now scope the claim to "because this project explicitly assigns the UI Actions reference" rather than generalising it. No behavioural change; the asset test is unchanged.
- 2026-09-01, architecture review F1 — world-space UI atlas and draw-call cost. §9's draw-call paragraph claimed world-space UI sprites "share the sprite atlas" and batch with DriftLayer content at no per-element cost. That contradicted ADR-0005 §2 (Accepted), which keeps the UI atlas separate and non-addressable while every DriftLayer atlas is packed inside a per-template
Land_<id>group "referenced by exactly one addressable" — so the claim would have put UI sprites inside a bundle that unloads atLandTornDown, dropping flyout sprites at teardown, and would have broken ADR-0005's single-consumer/no-duplicate-texture invariant. Corrected in §9 and in Performance Implications: world-space UI draws from the separate UI atlas at a fixed ≈ +2 draw calls (UI atlas + shared TMP font-atlas material), not zero. New V7 measures it as a marginal delta that must not scale with element count. The ms budgets (ui-panels0.0,ui-worldspace0.2) are unaffected — 0.2 traces tohud.md's own performance AC and never depended on the atlas claim.