---
description: "Deep audit of the game UI tracks — Scaleform/SWF (Ruffle host bridge, AVM1/AVM2 profiles, ABC adapter injection, archive navigator, offscreen wgpu readback, overlay upload, input routing) and the Oblivion/FO3/FNV MenuXml track (eval, layout, raster) with both HUD drivers"
argument-hint: "--focus <dimensions> --depth shallow|deep"
---

# UI Audit: Scaleform (R4 + M48) and MenuXml (M48.4-M48.7)

Read `.claude/commands/_audit-common.md` (delta-first scoping, dedup, finding format) and `_audit-severity.md` for shared protocol.

Audits the *host contract* that decides whether a Bethesda menu works, on two tracks that share one overlay compositing path. Orchestrator; one Task agent per dimension (max 3 concurrent).

## Scope

- **Scaleform**: `crates/ui/src/` (`lib.rs` `UiManager`, deliberately not an ECS `Resource`; `profile.rs`; `prepare.rs`; `host.rs`; `avm1_host.rs` Skyrim scanner; `avm2_host.rs` FO4 `BGSCodeObj` ABC injection; `catalog.rs`; `navigator.rs`; `player.rs` offscreen wgpu; `pacing.rs` render pacer; `input.rs`); protocol pins `crates/ui/tests/{hudmenu_protocol,fallout4_hudmenu_protocol}.rs`.
- **MenuXml**: `crates/menuxml/src/` (`eval`, `layout`, `raster`, `menu`, `profile` audited here; `parse`, `tex`, `font` are the parse side, owned by `/audit-parsers`).
- **Engine side**: `byroredux/src/{ui_input,hud,scaleform_hud}.rs`, `byroredux/src/commands/hud.rs`, `byroredux/src/app_frame.rs` (`tick_ui_overlay`, `tick_hud_overlay`), `app_events.rs`, `scene.rs` (`--menu`/`--hud` launch), renderer `crates/renderer/src/vulkan/{presentation.rs,texture.rs,context/post_passes.rs}`, `crates/renderer/src/texture_registry/dynamic_rgba.rs` + `crates/renderer/shaders/ui.{vert,frag}`.
- **Handoffs**: GPU teardown order `/audit-concurrency`; Ruffle/wgpu `unsafe` `/audit-safety`; MenuXml XML/DDS/`.fnt` parse robustness `/audit-parsers`; what a HUD *shows* (actor values, inventory) `/audit-gameplay`.

**Ground truth**: `docs/engine/ui.md` (host contract, MenuXml section, pending list), `docs/smoke-tests/README.md`.

**Deliberately unbuilt** (verified 2026-10-15 against `docs/engine/ui.md`; re-verify, do not trust this line): engine handlers for host methods (menus receive `Null`), Papyrus↔UI bridge, menu stack/focus policy, font fidelity, full menu pack, Starfield HUD bring-up (#4470 is closed, but only its parser blocker shipped: 9813af435 `normalize_scaleform_dialect`; no `ScaleformProfile::Starfield`, Starfield catalog, driver bars, protocol pin or `m48-8` smoke exists). Audit the *mechanism* that will carry them.

**Regression guards**: #3429 (closed; mechanism e2f99ad55): a same-extent `update_rgba` queues into `dynamic_rgba.rs` and is recorded in the next frame's command buffer (persistent image, per-slot staging, no fence wait); only an extent/format change recreates the image. A per-frame image create or fence wait on the overlay path is the regression (see Dim 5).

## Parameters

`--focus <dims>` (default all 7) · `--depth shallow|deep` (`deep` traces archive → SWF/XML → VM/eval → host call → pixels → GPU).

**Extra fields**: **Dimension**: Profile & Bridge | AVM2 Adapter | Catalog & AVM1 Scanner | Resource Navigator | Render & Overlay Upload | Engine Wiring & Input | MenuXml & HUD Drivers. **Profile**: `SkyrimAvm1` | `Fallout4Avm2` | `MenuXml` | both | n/a.

## Phase 1: Setup

1. `mkdir -p /tmp/audit/ui`; dedup per `_audit-common.md`; read the latest `docs/audits/AUDIT_UI_*.md` (date D = delta baseline).
2. `cargo test -p byroredux-ui -p byroredux-menuxml`; record pass/ignored. Data-gated tests **return early (silent pass) without game data**: `crates/ui/tests/*_protocol.rs` (`BYROREDUX_SKYRIMSE_DATA` via `test_paths::SKYRIM_SE_ENV`, `BYROREDUX_FO4_DATA`), and `avm1_host/tests.rs::installed_skyrim_host_calls_are_all_cataloged` is `#[ignore]`d. The menuxml corpora (`crates/menuxml/tests/{vanilla,fnv,fo3}_corpus.rs`) are `#[ignore]`d since #4660 — run them with `--ignored`; their DDS census pins measured per-set counts (Oblivion `textures\menus*` 1,975/1,712/1,789; FO3 / FNV `textures\interface\` 1,157 / 1,767; 065c9d31d, #5669). A "verified" claim resting on one of these is unverified unless you ran it with data.
3. **Count, do not trust, catalog sizes.** Re-derive from `crates/ui/src/catalog.rs`: Skyrim array = **142**, all `Measured` since 3df616c12 (#5274): 74 SkyUI-sourced + 68 #3103 corpus-sweep entries typed by their measured call-site arity; 126 commands + 16 requests (12 off the SkyUI sources, 4 by four-argument corpus sites: `LoadDLC`/`RequestLoadingText` #4720, `GetMouseButtonForSetDestination`/`ShouldShowMod` #5274). FO4 = **269** (138 `Measured` + 131 `HeuristicNamePrefix`, #2966; `HeuristicNamePrefix` is now FO4-only). A stray 74 claiming the whole catalog, or any Skyrim `HeuristicNamePrefix` count (the old 76/66 split), is doc rot.

## Phase 2: Dimensions

### Dim 1: Profile, Prepare & Host Bridge Transport
**Paths**: `crates/ui/src/{profile,prepare,host,lib}.rs`, `crates/ui/src/host/tests.rs`
**First step**: `git log --since=D -- crates/ui/src/host.rs crates/ui/src/prepare.rs crates/ui/src/profile.rs crates/ui/src/lib.rs`
**Guards**: `host/tests.rs::a_response_handler_may_re_enter_its_own_bridge` (handler is cloned out of the map before invocation; a refactor holding one borrow across the call re-introduces the panic); `prepare.rs` tests (`an_archive_menu_open_decompresses_and_parses_once`, `a_loose_avm1_movie_is_decompressed_once_and_never_parsed`, `a_profile_mismatch_is_rejected_before_any_further_decode`) via `SwfDecodeCounts`.
- Profile comes from the SWF header's AS3 bit (`ScaleformProfile::from_header`, `is_action_script_3()` on the decompressed header; Ruffle's movie type is `SwfMovieData` since the b91e82160 nightly-2026-10-10 bump), never from `--game` or archive provenance. A forced `new_with_profile` that contradicts the movie fails loudly or is test-only. The `external_interface_id` used at registration equals the id the bridge filters on (mismatch = every host call vanishes silently). Malformed SWF returns `Err`, not a half-built player.
- Queue: `MAX_QUEUED_CALLS` (1024) evicts oldest with `pop_front`, counts each eviction, warns once. A drained batch may be non-contiguous; the engine (`app_frame.rs`) latches `dropped_host_calls` per menu and `host_call_gap_for_menu` resets it on a menu swap. Regression = comparing against zero or dropping the latch.
- Every bounded set (`callbacks`, `known_methods`, `unknown_methods`, `unanswered_methods` via `insert_bounded`; the player's error/load lists) caps at `MAX_DISTINCT_HOST_METHOD_NAMES` and logs once at the trip. The adapter's lifecycle callbacks are exempt from the cap by **exact membership** in `RESERVED_ENGINE_CALLBACKS` (3 names, built from the `avm2_host` constants; worst case per set = cap + 3), so movie-registered `__byro*` spoofs cannot lock out `__byroBGSCodeObjReady`/`Loaded`/`Destroy` (8974dec12, #4719 retired the prefix-keyed 32-wide band). A prefix test reappearing anywhere is the regression (the `scaleform_hud.rs` push no longer has a skip at all, Dim 7). The guard sits in `insert_bounded`, the single choke point for all sets; check a new set goes through it.
- **Starfield PlaceObject3 dialect shim** (#4470, 9813af435): `prepare.rs::normalize_scaleform_dialect` runs before every `prepare_movie` stage (and on the navigator's dependency path) and sets MOVE only on PlaceObject3 records with neither MOVE nor HAS_CHARACTER, recursing into DefineSprite (depth-capped at 1: nested sprites are illegal SWF, and an uncapped descent overflowed the stack on a ~10 B/level crafted movie, 8872f3725, #5456; `deeply_nested_sprites_do_not_overflow_the_stack`, `one_level_of_sprite_nesting_is_still_patched`) and re-emitting FWS at a fixed length (`prepare.rs` tests). It must stay length-preserving, fail closed on a malformed body (a CWS body overrunning its declared length is declined, `cws_body_longer_than_its_declared_length_is_declined`; the inflate is bounded by the header's own length **and** the absolute `MAX_SWF_BODY_BYTES` ceiling, 128 MiB, ~2x the largest shipped movie (FO76 `fonts_zhhant.swf`, 65,902,267 B): a header claiming up to `0xFFFFFFFF` over a ~4 MB zlib bomb is declined before any inflate, d601b45ab, #5719; `cws_header_claiming_u32_max_over_a_bomb_is_declined`, `swf_body_ceiling_clears_the_corpus_and_stops_short_of_u32`, `cws_within_the_ceiling_inflates`), and own the prepare path's single decode (15b65b1b9, #5479): on CWS it inflates once and ALWAYS returns the uncompressed file (patched or not) so `swf::decompress_swf` takes the uncompressed copy path; on FWS it returns `Some` only when a record changed; it returns `None` for ZWS, conforming FWS and foreign containers (`conforming_cws_returns_its_decode_with_an_untouched_body`). A shim that inflates the raw bytes separately from the prepare decode is the #5479 regression (`SwfDecodeCounts::decompresses` is the real count).
- One SWF decode per open: `prepare_movie` → `PreparedMovie`; floor is two inflates + one tag walk. A stage taking raw bytes again is the regression; `--menu` passes `profile: None` and reads `UiManager::menu_profile()` (no pre-extract-and-detect).
- `SwfPlayer::invoke_callback` consults ruffle's own external-interface registry (not only the bridge's recorded name set) before invoking, and prunes a stale name: AVM2 `addCallback(name, null)` unregisters a callback behind the bridge, and invoking it would otherwise reach ruffle's unknown-callback warn and return a `Null` indistinguishable from a live callback's answer (86f5a1a0a). `None` = unknown/unregistered, `Some(Null)` = registered callback returning null. The prune and the empty-URL skip are pinned (b7760069a, #5732: `host/tests.rs::invoking_an_unregistered_callback_prunes_and_returns_none` over the real AVM2 fixture, `navigator.rs::an_empty_import_assets_url_is_skipped_silently`).
- `ScaleformValue` round-trips in both directions with unrepresentable values becoming explicit `Null`; `ScaleformHostDispatch::MissingResponse` (Request with no response) stays distinct from `Unknown`.
- The bridge is `Rc`/`RefCell` (Ruffle is single-threaded): nothing hands a clone to another thread; `UiManager` stays out of the ECS resource set (`ScaleformHudDiag` is the console mirror).
**Output**: `/tmp/audit/ui/dim_1.md`

### Dim 2: AVM2 Adapter Injection (FO4 `BGSCodeObj`), highest risk
**Paths**: `crates/ui/src/avm2_host.rs`
**First step**: `git log --since=D -- crates/ui/src/avm2_host.rs`
**Guards**: `avm2_host.rs::no_other_injected_name_is_a_prefix_of_another`, `generated_adapter_pool_carries_no_abandoned_loader_strategy`, `crates/ui/tests/fallout4_hudmenu_protocol.rs` (data-gated). Bytecode surgery on a third-party binary: a wrong constant-pool index yields a movie that loads and misbehaves, and no test fails.
- Every index written is one the rewriter added or verified (append-then-reference ordering). Injection is idempotent (menu reload / resize rebuild must not duplicate helpers or traits).
- `ScaleformHostObjectState` has four variants (`NotRequired`, `NotPresent`, `AdapterInjected`, `AdapterInjectedWithoutDestroyHook`). The re-injection probe scans for `DESTROYED_EVENT`, not its strict prefix `DESTROY_CALLBACK`; the four destroy strings are emitted together or not at all. Both `scene.rs` menu-load log sites call `UiManager::host_object_state()` (else `NotPresent` logs like a healthy menu).
- The destroy callback is registered only when the movie's class declares `onCodeObjDestruction`; `AdapterInjectedWithoutDestroyHook` does not increment `code_object_destruction_count()`.
- One shared helper normalises `BGSCodeObj.Method` → `Method` while keeping the transport name in `ScaleformHostCall` (not 269 per-method copies).
- Degradation: an unparseable ABC tag or no lifecycle-class match returns the *original* bytes with `NotPresent` + `log::warn!`; the hard `Err` is kept only for `patch_root_constructor` (a partial rewrite would corrupt the SWF). No third branch may silently hard-fail.
**Output**: `/tmp/audit/ui/dim_2.md`

### Dim 3: Catalog Fidelity & the AVM1 Scanner
**Paths**: `crates/ui/src/{catalog,avm1_host}.rs`, `crates/ui/src/avm1_host/tests.rs`
**First step**: `git log --since=D -- crates/ui/src/catalog.rs crates/ui/src/avm1_host.rs`
**Guards** (default lane): `catalog.rs::{skyrim_catalog_is_sorted_and_unique, fallout4_catalog_is_sorted_and_unique, fallout4_catalog_provenance_split_matches_the_2966_sweep, skyrim_catalog_is_fully_measured, the_skyrim_measured_kinds_match_their_call_site_arities}` (`find` is a case-sensitive `binary_search_by`, so sortedness under `str::cmp` is its prerequisite). Data-gated: `installed_skyrim_host_calls_are_all_cataloged` (Phase 1).
- Diff the re-derived counts (Phase 1 step 3) against every number in `docs/engine/ui.md`, ROADMAP and prior reports; mismatch = doc rot.
- **Fourth-argument rule** (#4720, 588085f20; #5274, 3df616c12): the AVM1 scanner records each resolved site's `CallMethod` arity as a `(min, max)` pair (`Avm1HostCallInventory::arg_counts`), so a mixed-arity name fails the corpus sweep instead of collapsing onto the max; the sweep pins four-arg ⇔ `Request` for every entry it sees (floor 16 four-arg methods so a quiet scanner fails loudly; synthetic pin `a_four_argument_site_records_the_callback_bearing_count`). A new Skyrim entry must arrive `Measured`; a name-prefix guess there is the regression.
- Only FO4 sweep entries may be `HeuristicNamePrefix` (its #2966 sweep was name-only, no arity); a `Command`/`Request` mis-type on a heuristic entry is lower confidence than on a `Measured` one. `kind` only selects a diagnostic bucket (`record_call` queues every call and answers from the configured response regardless), so a misclassification moves a name between `unanswered_methods()` and `Queued`; it cannot drop a call. Spot-check high-traffic methods against the installed-ABC/SkyUI evidence in `docs/engine/ui.md`.
- AVM1 scanner: `GameDelegate.call` operands are read right-to-left; function bodies are nested byte slices and the constant pool descends with the walk; the stack is cleared on unmodelled actions (can lose a site, never invent one); `Avm1HostCallInventory::unresolved` counts recognised-but-unnamed sites. Runtime-named calls (`this.callbackName`) are invisible to a static walk; the catalog is a union, not a proof of completeness.
- `unknown_methods()` is live, not test-only: the frame driver warns once per `(menu, method)` (`ui_reported_host_methods`, bounded by the same cap) and the set is not cleared per frame or per menu load.
**Output**: `/tmp/audit/ui/dim_3.md`

### Dim 4: Resource Navigator (archive-backed loads)
**Paths**: `crates/ui/src/navigator.rs`, `crates/ui/src/player.rs` (load side)
**First step**: `git log --since=D -- crates/ui/src/navigator.rs`
**Guards**: `archive_menu_route_tests` in `byroredux/src/scene.rs` (CLI-argument parser only); `docs/smoke-tests/m48-menu-load.sh` (Vulkan + game data) is the only end-to-end gate.
- Movies are **untrusted content**; URL→archive resolution is confined to the game archives (`archive_movie_url` rejects `..` escapes; `resolve_url` joins against the movie URL). Verify absolute paths, non-archive schemes and network/filesystem escapes are refused (escape = HIGH); one backslash/case normalisation point matching `byroredux/src/asset_provider/`.
- The local-executor pump runs from the same place the player ticks; a load future that is never polled hangs the menu silently unless surfaced via `resource_error`. `resource_loads()` records misses as well as hits.
- Bounds: `resource_loads` dedups by `archive_path` with a hit counter and caps at `MAX_RECORDED_RESOURCE_LOADS`; `import_asset_paths` caps at `MAX_IMPORT_ASSET_PATHS` (512). An `ImportAssets` record with an **empty** URL is skipped (upstream Flash Player does not load it; joining an empty relative URL resolves to the movie itself and emitted a bogus archive path, 86f5a1a0a). One unresolvable `ImportAssets` URL is a recorded non-fatal error on both the root scan (`PreparedMovie::root_import_errors`) and nested scans; a `Result`-collecting `.collect()` reappearing turns one bad import into a whole-menu failure.
**Output**: `/tmp/audit/ui/dim_4.md`

### Dim 5: Render Path, Overlay Upload & Device Lifecycle
**Paths**: `crates/ui/src/{player,pacing}.rs`, `byroredux/src/app_frame.rs`, `byroredux/src/hud.rs` (`upload_frame`, `render_and_upload`), `crates/renderer/src/vulkan/{texture.rs,presentation.rs}`, `crates/renderer/src/texture_registry/{mod,dynamic_rgba}.rs`, `crates/renderer/src/vulkan/context/{post_passes,build_and_upload_instances}.rs`
**First step**: `git log --since=D -- crates/ui/src/player.rs crates/renderer/src/vulkan/presentation.rs crates/renderer/src/vulkan/texture.rs byroredux/src/hud.rs`
**Guards** (source-shape, default lane): `presentation.rs::ui_overlay_composites_after_the_tone_map_draw`; `lib.rs` test on `UiFrame::Hidden => {}` in `app_frame.rs`; `dynamic_rgba.rs::rgba_updates_use_the_frame_command_buffer_and_keep_descriptors`. None can see pixel stride, image hazards or device lifetime; say so instead of claiming verification.
- **Second GPU device**: the UI creates its own wgpu device on Vulkan beside `VulkanContext`. Quantify device + allocator + `TextureTarget` against `docs/engine/memory-budget.md` and *feedback_vram_baseline*; a per-menu device never reused is a leak class. Creation failure leaves the engine running with UI off, and a transient failure is not cached as permanent.
- `UiManager::render()` returns `UiFrame::{Fresh, Unchanged, Hidden}`: `Unchanged` reuses the previous handle, `Hidden` stops the UI quad (a hidden overlay must not keep compositing its last frame). Pixel format and row stride from `capture_frame` match the upload (a mismatch is a sheared overlay `cargo test` cannot see). `UiManager` size, `TextureTarget` size and `register_ui_quad` extent move together on resize with no orphaned target.
- **Overlay composites after tone-mapping**, inside the presentation pass at output resolution: the quad in the geometry pass (fog, bloom, TAA, FSR, ACES) is the regression. Both descriptor sets are rebound before the overlay draw (the tone-map draw binds an incompatible set 0); the UI pipeline is owned by `PresentationPipeline` and rebuilt only in its recreate.
- **`MAX_INSTANCES` overflow**: `ui_instance_idx` is captured immediately after the push and becomes `None` when it lands past the cap, at capture time, not after `UiOverlayDraw` is built (else `firstInstance` reads out of range; `robust_buffer_access` is off).
- **In-frame dynamic RGBA upload (both HUD paths)**: `update_rgba` (Scaleform, same extent) and `write_rgba_inplace` (MenuXml; extent/format mismatch is an error) both queue into `DynamicRgbaUploads` — latest pixels coalesce per handle, `record_pending_rgba_uploads` (called from `begin_frame_recording.rs`) copies them into the frame command buffer from a per-slot staging arena (`MAX_FRAMES_IN_FLIGHT` = 2, `crates/renderer/src/vulkan/sync.rs`) and requires the slot be fence-idle; an update is acknowledged only by `submitted(slot)`, and a re-queue after recording resets `recorded_slot` so newer bytes are never marked done. `MenuXmlHud` overwrites **one** texture in place (cfaccff57, #4892 retired the three-handle rotation; the registry barriers order earlier frames' sampling before the copy) and `overwrite_rgba_pixels` is deleted (its guard now pins `Texture::can_update_rgba`). Staging failures (arena growth, mapped write, flush) degrade to a skipped overlay frame, warn once per episode and keep the dirty pixels queued (792c56de3, #4889; `staging_failures_skip_the_overlay_frame_instead_of_failing_it`). The growth attempt itself backs off during an episode (e588e5e67, #5528): `GrowthBackoff` retries after `RETRY_INTERVAL_FRAMES` (120) frames that would have attempted growth, or at once for a smaller request than the one that failed, and any success clears it, so a persistent BAR shortage no longer pays one failing `vkAllocateMemory` per frame (`growth_retries_back_off_during_a_failure_episode`, `growth_gate_sits_before_the_allocation`). The warning episode re-arms only at the end of a fully-recorded `record_pending_rgba_uploads`, never in `submitted` (the skip frame itself submits Ok; 7e43a1550, #5275; `a_submitted_skip_frame_does_not_rearm_the_episode_warn`). Only the fence-idle sequencing check may propagate an `Err` into `draw_frame`, so a new `?` in the recording path is the regression. A barrier finding here needs validation-layer or RenderDoc evidence (*feedback_speculative_vulkan_fixes*), not a reading. #4608: `render_and_upload` rasterizes into a persistent `upload_buffer` (no per-change `to_vec`).
- **Render pacing (#4717)**: `RenderPacer` (`pacing.rs`, pure, clock = summed `dt`) gates `SwfPlayer::render`'s Ruffle pass + blocking readback: active cadence while the last readback changed or a host write (`handle_input`, `set_mouse_in_stage`, `invoke_callback`, `set_stage_transparent` → `invalidate`) woke it, one idle probe per interval after N identical passes. Opt-in (`set_render_pacing`): only the Scaleform HUD installs it (`HUD_RENDER_PACING`); modal menus stay unpaced. A new host-write path that skips `invalidate` leaves input visually stale for up to the idle interval.
- `SwfPlayer` (and its wgpu device) drops before the Vulkan allocator tears down; report the ordering here, the teardown finding in `/audit-concurrency`.
**Output**: `/tmp/audit/ui/dim_5.md`

### Dim 6: Engine Wiring & Input Routing
**Paths**: `byroredux/src/{ui_input,app_frame,app_events,scene,scaleform_hud}.rs`, `crates/ui/src/{input,lib}.rs`, `byroredux/src/main.rs` (`route_scaleform_window_event`)
**First step**: `git log --since=D -- byroredux/src/ui_input.rs byroredux/src/app_events.rs byroredux/src/scaleform_hud.rs`
**Guards**: `ui_input.rs` tests (`focus_release_does_not_recapture_or_rotate_on_return`, `mouse_look_requires_both_window_focus_and_capture`).
- Focus is a two-state contract: a focused menu stops world input; releasing focus leaves no key stuck and does not recapture the cursor or rotate the camera on return (`release_world_input` fires once; `apply_mouse_look` needs window focus **and** capture). A menu open/close mid-strafe leaves the camera still.
- Dispatch order: egui debug overlay → focused Scaleform menu → world. `is_debug_overlay_key` is checked before the UI swallow. egui's "consumed" flag is honoured only through `honour_egui_consumed` (app_events.rs), i.e. while an interactive egui surface shows (`DebugUiState::captures_gameplay_input`: F3 overlay or a player menu page): egui-winit consumes Tab unconditionally, so a bare `if egui_consumed { return; }` swallowed the Tab→Inventory binding every frame from boot (10cddd22e; `consumed_is_only_honoured_while_an_interactive_surface_shows`, `window_event_gates_consumed_through_the_predicate`), while event forwarding to egui stays unconditional (#2831). A menu-open frame leaking an `InputAction` edge into `combat_input_system` / `interaction_system` is a finding here.
- winit → `UiInputEvent`: physical vs logical key, mouse buttons, wheel units (line vs pixel) and IME each translated once; `crates/ui/src/input.rs` stays winit-free; `set_mouse_in_stage` updates on every motion event including leaving the window.
- **HUD route is not a modal menu**: `scaleform_hud::launch` keeps world input (`set_input_focus(false)`), sets the stage transparent, and yields to `--menu` when a `UiManager` already exists — both `--hud` routes (Scaleform and MenuXml) refuse through the one shared `hud::menu_owned_overlay_skip` (a7d3222a8, #4723; `a_menu_owned_overlay_blocks_both_hud_routes`), so `--menu` plus a MenuXml `--hud` can no longer strand a focused, never-ticked `UiManager`; the Scaleform probe runs first and a won Scaleform route suppresses the MenuXml launch (mutually exclusive per run). `hud.off` must really hide it (`UiFrame::Hidden` for Scaleform, early return for MenuXml).
- Native egui pages (`crates/debug-ui`) are modal too: every path leaving the dialogue page (Close, Escape via `toggle_game_menu`, the Inventory key via `open_inventory_menu`) runs `App::leave_dialogue_page`, sampled *before* its visibility flip (14b0d33f2, #5409; `every_native_page_leave_path_routes_through_the_shared_teardown`). A new leave path that only flips visibility skips the OnEnd fragment (what OnEnd does is `/audit-gameplay`).
- Per-frame cost is timed into `bench_ui_ns`; tick+render happen once per frame and are skipped when no menu is loaded.
**Output**: `/tmp/audit/ui/dim_6.md`

### Dim 7: MenuXml Track & the HUD Drivers
**Paths**: `crates/menuxml/src/{eval,layout,raster,menu,profile}.rs`, `byroredux/src/{hud,scaleform_hud}.rs`, `byroredux/src/commands/hud.rs`, `docs/smoke-tests/m48-{4,5,6,7}-*-hud.sh`
**First step**: `git log --since=D -- crates/menuxml/src byroredux/src/hud.rs byroredux/src/scaleform_hud.rs docs/smoke-tests/`
**Guards**: `crates/menuxml/src/tests.rs` (synthetic eval/layout/raster/graft, default lane); data-gated corpus tests and protocol pins (Phase 1 step 2); smoke gates `m48-4-oblivion-hud.sh` (health-fill run 158→53 px after a pin), `m48-5-fo3-hud.sh` (tick columns 226→68), `m48-6-skyrim-hud.sh` / `m48-7-fo4-hud.sh` (chrome on/off pixel diff; **bars are not gated**, vanilla feeds them by GFx object-path calls Ruffle cannot make). Smokes need a Vulkan device + game data; `m48-menu-load.sh` and every `m48-4..7` script, `m48-5-fnv-hud.sh` included (c47b2d72d, #5276), exit 77 (SKIP) on missing data and are enforced by `scripts/check-playable-smoke-contracts.sh` (161ab5ae8, #4724). A new `m48-*` smoke missing from that checker's list is the regression.
- **Eval**: a trait's operator chain is a *fold* (each op sees the previous working value; booleans are 2/0; `onlyif`); reads are on-demand and memoised per frame with `MAX_READ_DEPTH` (32) cutting cycles to 0, so a self-referential mod menu cannot hang. Engine overrides are keyed by lower-cased tile **name**: duplicate names (grafted prefabs, FO3 `hp_meter`/`ap_meter`) share one override, so verify grafted tiles get unique names. `ScreenTraits` `cropx`/`cropy` implement the 4:3-safe insets. Menu-texture lookup (`menu.rs`) keeps trying resolution-set candidates past an undecodable one and warns "present but undecodable" apart from "not found" (5c53ad9c7, #5400; the decoder itself is `/audit-parsers`). Decoded textures are shared by **resolved archive path** (the `Menus\` / `Menus80\` / `textures\menus\` spellings of one file read and decode once) and charged against `MAX_DECODED_TEXTURE_BYTES` (1 GiB) from the DDS header alone (`Rgba8::decoded_len`) before allocating; over budget the filename resolves to nothing (cached, one warning per renderer) while already-decoded art keeps drawing (5dce486b6, #5658; the budget exceeds every game's whole menu-texture set, 979.9 MiB Oblivion). Refusals are memoised per **resolved archive path** and consulted before the fetch, so one over-budget file is extracted for a budget decision at most once however many spellings name it; a refused candidate continues to its siblings (a smaller resolution-set variant can still serve) and the warning no longer claims later textures are refused, the check being per texture (4c979297b, #5693). When a lower-priority resolution set serves a tile because the preferred copy is undecodable, `menu.rs` warns once per authored spelling naming the undecodable paths (64bca63af, #5670; font atlases take the first present atlas and have no fallback to signal). An uncached, per-spelling or post-fetch-only decode path is the regression; a new decode site must be count-bounded or charged. **Include splicing** (`parse.rs`): bounded by `MAX_INCLUDE_SPLICES` 256, `MAX_INCLUDE_BYTES` 256 KiB, `MAX_INCLUDE_FRAGMENT_BYTES` 64 KiB, `MAX_INCLUDE_FETCHED_BYTES` and `MAX_DOCUMENT_TILES` 16,384; every fetch charges `max(len, 1 KiB)` against the fetch budget so the per-document extract count stays bounded, and an exhausted-budget cause (budget/bytes/fetched/tiles) warns once per document naming the first offending path while per-include causes (cycle/oversize/missing) keep per-spelling warnings (547d3c385, #5668). The cycle check runs only on the *resolved* include after the per-document cache lookup, so *menus\x.xml* including *x.xml* no longer drops the distinct *menus\prefabs\x.xml* as a false cycle (ba252c482, #5329; `a_distinct_higher_priority_include_is_not_a_cycle`, `self_including_fragment_costs_one_fetch`).
- **Layout**: locus chain for x/y, stable depth sort (document order tiebreak), `visible`/`alpha 0` skip, `clipwindow` intersection. Negative, NaN or huge authored extents must not panic or explode work.
- **Raster**: `blit`/`blit_sub` clamp each loop axis to the framebuffer via `axis_span` (in f32, before the int cast) and draw nothing on non-finite geometry/crop/zoom/clip; tiled texel math uses wrapping arithmetic (#4715; `a_huge_finite_extent_visits_at_most_the_framebuffer`, `non_finite_geometry_draws_nothing`, `the_glyph_path_is_bounded_and_rejects_non_finite_geometry_too`). A new draw path that loops on authored extents without `axis_span` is the regression. Layout depth sort uses `f32::total_cmp` with the sign of zero folded (#4716, `nan_depth_siblings_do_not_panic_the_depth_sort`). Zoom contract: `zoom<0` stretches, `0`/`100` draws texels 1:1 clipped to the tile rect (stretch-as-default squeezes padded ribbon art into a constant-width bar), `>0` scales, crop applies after zoom; `tiled` repeats 1:1 with `cropx` as a wrapping scroll (compass strip); text wrap uses the font's metrics.
- **HUD drivers (`hud.rs`)**: `fraction()` reads `PlayerEntity`'s `ActorValues` only (#4675; `hud_bars_resolve_through_player_vitals_and_read_the_player_only`), ceiling = `ActorValue::undamaged()` (base + permanent + temporary + the `set_override` layer) rather than a hand re-sum, else a routed player `SetBase` clamps the bar full (627149b62, #5508; `fraction_max_includes_the_set_override_layer`); bar tables carry canonical AVIF **editor ids** resolved per load through `PlayerVitals` — a literal AVIF FormID in a bar table is the regression (the old 0x2C9/0x2D0 were test-fixture ids; FO4 0x2C9 is Experience). FO3 vs FNV is decided by `fallout_profile` from the load order (`--master`/`--esm`, then the base ESM beside the corpus), not the `--esm` name (#4718; `a_new_vegas_dlc_launch_resolves_the_new_vegas_profile`, smoke `m48-5-fnv-hud.sh`). Unchanged-skip: change signature (heading quantised to 0.1°) plus a 33 ms `HUD_REFRESH_INTERVAL` cadence cap; the rate limiter must leave the signature unset so a throttled change is not lost. Compass heading = `atan2(f.x, -f.z)`. Per-game facts live in `HudGameProfile`/`MenuProfile` (font table, strings source, AVIF editor ids; Skyrim's stamped 0x3E8-0x3EA): a per-game branch outside the profile is a doctrine violation (*feedback_format_translation*).
- **Scaleform HUD (`scaleform_hud.rs`)**: vanilla Skyrim `hudmenu` calls exactly `GetButtonFromUserEvent`/`PlaySound`/`RegisterHUDComponents`/`myLog` and registers only the `GameDelegate` `call`/`respond` pair; FO4's makes zero host calls at idle. `ScaleformHudDriver::tick` must copy `HudControl` out and drop the read guard in the same statement before its `live`-bars write (same-thread deadlock, 2b5bdebd8; `tick_publishes_live_bars_without_deadlocking_on_hud_control`). The driver's `updateStats`/`RequestPlayerInfo` poll handlers are per-game table data (`ScaleformGame::poll_handlers` + `PollPayload`, #4725; Skyrim two entries, FO4 none; `poll_handlers_are_per_game_table_data`) and with the push table answer SkyUI-class menus, not vanilla; they must stay catalog-consistent. The push carries no reserved-name skip since 431f74ce7 (#5278): its four gameplay arms (`UpdateStats`/`UpdateCompass`, both casings) end in `_ => continue`, which declines the adapter hooks. Its guard `the_push_table_never_targets_a_reserved_engine_callback` checks only that those four appear in `push` and are disjoint from `RESERVED_ENGINE_CALLBACKS`. It does not fail on a *new* arm naming a reserved hook (despite its doc), so read the arm list; a `__byro` prefix literal anywhere is the regression. Response shapes are working hypotheses: unverified until a menu polls them.
- **Console pins** (`byroredux/src/commands/hud.rs`): `hud.values`/`hud.heading` reject NaN/±inf before clamp/`rem_euclid` (cb7874760, #5280); a pin path without `is_finite` blanks the MenuXml bar (raster non-finite reject) or pushes `Number(NaN)`.
**Output**: `/tmp/audit/ui/dim_7.md`

## Phase 3: Merge

Combine `/tmp/audit/ui/dim_*.md` into `docs/audits/AUDIT_UI_<TODAY>.md` (header per `_audit-common.md` Report finalization): Executive Summary (findings by severity; which profiles were traced end to end; re-derived catalog counts vs documented), **Host Contract Matrix** (profile × {detection, host object, transport, catalog size, live consumer, HUD route} verified/drifted), Findings (deduplicated), **Pending-Row Readiness** (invariants pinned for the unbuilt handlers / Papyrus bridge / menu stack; never listed as findings). Then `rm -rf /tmp/audit/ui`; suggest `/audit-publish docs/audits/AUDIT_UI_<TODAY>.md` (labels `ui`; add `legacy-compat` for menu-fidelity findings and `game:*` when one title's menus are the cause).
