matiaszanolli

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

"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"

matiaszanolli 28 3 Updated 1w ago
GitHub

Install

npx skillscat add matiaszanolli/byroredux/claude-commands-audit-ui

Install via the SkillsCat registry.

SKILL.md

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-12 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. 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 length), 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.
  • 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). An uncached or per-spelling decode path is the regression; a new decode site must be count-bounded or charged.
  • 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).