GENERATED VIEW · PRIVATE ARCHIVE · DO NOT SERVE
RobCo Industries · Archive Museum

PERFORMANCE_AUDIT.md



RELEASE

planning/2.8.0/audits/PERFORMANCE_AUDIT.md

sha256 6fbc8e4229620fce · 22073 bytes · original held in the private archive

# PERFORMANCE_AUDIT.md — RobCo U.O.S. v2.6.0 > **FILED UNDER 2.8.0 — PRODUCED-BY, NOT MEASURED-VERSION.** This document *measures* the earlier v2.6.0/v2.7.0 > codebase, but it was *produced* as a stage of the PARKED-CHAIN audit sequence — the analysis phase that became > the 2.8.0 overhaul. Planning artifacts are filed by the release whose work produced them, never by the version > they discuss. The version in the title is the code it audited, not the folder it belongs in. > **[ARCHIVE-class snapshot — not current truth.]** Per the 2.8.5 U-B1 doc-maintenance model (see `CLAUDE.md`'s > Reference Pointer Index), this doc is frozen as of the date below and is never updated to track current code. It > audited a codebase roughly 30% smaller than today's (pre-`test-console.js`, pre-OCR, pre-Diagnostic-Shell, > pre-CHASSIS/Overseer). Reuse its structural reasoning as historical input only — re-verify every measurement and > file-split proposal against current code before acting on it. > Stage A1 of the PARKED-CHAIN audit sequence. PLAN-ONLY. Nothing here is implemented or committed. > Companion docs: HOUSE_STANDARD.md (A0), DIEGETIC_AUDIT.md (A0). > Generated from: bc8c2eb (v2.6.0-r1, 1078 tests). --- ## 1. Asset & Cache Budget ### Precached payload (sw.js ASSETS list) | Asset | Size | Notes | |---|---|---| | `index.html` | 75.3 KB | Also listed as `'./'` — duplicate cache entry, same content | | `icon.png` | **196.0 KB** | Main PWA icon — largest single file | | `comm-link-icon.png` | 40.6 KB | Shortcut icon — only used by OS launcher | | `inventory-icon.png` | 36.2 KB | Shortcut icon | | `new-campaign-icon.png` | 33.6 KB | Shortcut icon | | `stats-icon.png` | 26.8 KB | Shortcut icon | | **Icon subtotal** | **333.2 KB** | **34% of total cache** | | `reg_nv.js` | 89.3 KB | NV registry data; only one reg executes per session | | `ui-render.js` | 78.4 KB | All render*() functions | | `ui-core.js` | 74.8 KB | loadUI(), onload, audio coordination | | `api.js` | 61.1 KB | AI contract, autoImportState, transmitMessage | | `terminal.css` | 45.1 KB | All styles | | `reg_fo3.js` | 45.9 KB | FO3 registry — cached but not executed in FNV sessions | | `db_nv.js` | 44.2 KB | Inline NV CSV data | | `ui-audio.js` | 27.5 KB | Web Audio synthesis | | `cloud.js` | 23.8 KB | Cloud sync (ES module) | | `state.js` | 23.4 KB | State, save, migration | | `ui-saves.js` | 23.0 KB | Save slots, file IO, autocomplete | | `db_fo3.js` | 24.3 KB | FO3 CSV data — cached but not executed in FNV sessions | | `ui-account.js` | 6.7 KB | Auth panel | | `sw.js` | 2.9 KB | Service worker | | `manifest.json` | 2.0 KB | PWA manifest | | **JS subtotal** | **522.4 KB** | | | **CSS subtotal** | **45.1 KB** | | | **Grand total** | **~978 KB** | ~1 MB cold-cache download | ### Runtime JS executed before TTI (FNV session) The boot loader injects three blocking scripts synchronously before any UI shows: ``` db_nv.js (44.2 KB) → state.js (23.4 KB) → reg_nv.js (89.3 KB) = 157 KB ``` Then six more static `<script src>` tags (also blocking, no `defer`): ``` ui-audio.js + ui-render.js + ui-saves.js + ui-account.js + ui-core.js + api.js = 271.5 KB ``` **Total JS parsed + executed before `window.onload`**: ~428.5 KB (FNV) / ~394.4 KB (FO3). `cloud.js` (23.8 KB) loads as an ES module — deferred, does not block onload. --- ## 2. Boot / Loader Chain ### Sequence (FNV) ``` 1. HTML parses head — CSS link blocks render 2. Inline optics script — reads localStorage, sets CSS vars (fast, sync) 3. Body parses — bootScreen + all panel HTML (~75 KB) 4. Boot loader IIFE (inline script, body-end): reads localStorage.robco_v8 to detect context injects: <script async=false src="js/db_nv.js"> <script async=false src="js/state.js"> <script async=false src="js/reg_nv.js"> async=false ensures order but BLOCKS parser until each downloads + executes 5. Static <script src> tags parse in order (also blocking): ui-audio.js → ui-render.js → ui-saves.js → ui-account.js → ui-core.js → api.js 6. <script type="module" src="js/cloud.js"> — deferred, non-blocking 7. window.onload fires (after ALL static + dynamic scripts): snapRollingBackup() — JSON.parse + localStorage read load robco_v8 — JSON.parse faction migration guard DOM value writes (apiKey, model, etc.) JSON.parse robco_chat — replays all messages into chatDisplay loadUI() ← the main render fan-out initTabs(), setupHpBarInteraction(), startCrtHum() initRegistryAutocomplete(), initAmmoDatalist(), initLocationDatalist() runBootSequence() ``` ### Issues **B-1 No `defer` on static UI scripts.** `ui-render.js` (78.4 KB) and `ui-core.js` (74.8 KB) are loaded as blocking scripts. They could be `defer`-ed since they register functions but do nothing until `window.onload` runs. Deferred scripts execute after HTML parses but before `load` — same net timing for `window.onload`-gated code, but frees the parser earlier. **B-2 Chat history replay on every boot.** If `robco_chat` has up to 200 messages, `appendToChat()` is called 200 times in a loop during `window.onload`, each creating a DOM element. This blocks the load pipeline in proportion to chat size. **B-3 `'./'` duplicate in ASSETS list.** The SW caches both `'./'` and `'./index.html'`. On GitHub Pages these serve identical content. The `cache.addAll(['./'])` stores the root response as a separate cache key. Not a correctness issue, but adds one network request and one cache entry. --- ## 3. loadUI() — Full Render Fan-out (KNOWN DEBT, Biggest Win) ### What it does `loadUI()` (ui-core.js:1337) calls **23 render*() functions** plus direct DOM manipulation on every invocation: ``` renderSkills() — rebuilds all skill inputs from scratch renderInventory() — full list rebuild renderAmmo() — ammo grid rebuild renderSquad() — NPC companion list renderStatus() — status effects list renderCampaignNotes() — notes list renderFactionRep() — 14 faction cards × 4 buttons + rep bar each renderPerks() — perks list renderQuests() — quest list renderSessionStats() — stats grid renderEquipped() — equipped slots renderCollectibles() — all 20–27 collectibles renderLincolnMemorabilia() — 9 Lincoln items (FO3 only, hidden in FNV) renderTraits() — 16 traits (FNV only) renderSkillBooks() — 13 books × read/unread split renderMagazines() — 14 magazines × read/unread split renderCraft() — recipe select + scrap select (rebuilds <optgroup>/<option> elements) renderGameDate() — calendar display renderWorldMap() — 6×6 grid + fuzzy zone scoring (see §4) renderKarmaCenter() — FO3 karma display renderCampaignStatus() — campaign status panel renderAccount() — auth panel renderSavesList() — unified save list (local + cloud) ``` Plus: ~20 direct DOM field writes (SPECIAL stats, limb buttons, HP/XP bars, dropdowns), `updateMath()`, `_updateContextPanels()`, `triggerPhosphorGhost()`, radiation debuff coloring. **Every loadUI() call re-renders all panels regardless of what actually changed.** ### Call sites — how often it fires | Call site | File | Trigger | |---|---|---| | `window.onload` | ui-core.js:174 | Boot (once) | | `autoImportState()` | api.js:849 | Every AI response | | `_nativeSleep()` | api.js:1027 | `[SLEEP]` native command | | `_nativeWait()` | api.js:1041 | `[WAIT N]` native command | | `loadFromSlot()` | ui-saves.js:177 | Load save slot | | `toggleLimbCripple()` | ui-core.js:1286 | Limb tap | | Cloud pull (account panel) | ui-account.js:164 | Pull from cloud | Every **AI response** is one full loadUI() — 23 render*() calls — even if the AI only changed `state.loc` (one string field) or a single stat. ### Scope of wasted work per AI sync (typical session) - `renderFactionRep()` rebuilds all 14 faction cards with 4 buttons each — even when factions are unchanged - `renderCraft()` re-injects `<optgroup>` HTML and resets the select — even when inventory is unchanged - `renderWorldMap()` runs full fuzzy scoring across 36 zones — even on the STAT or INV tab - `renderSkillBooks()` / `renderMagazines()` rebuild sub-panels even if no books/magazines changed ### Proposed fix — SPEC-FIRST **Dirty-diff aware rendering.** Before each render*() call, snapshot the relevant state slice and compare to a previous snapshot. Skip the DOM rebuild if the slice is unchanged. Design options: 1. **Per-panel dirty flags** — `autoImportState()` sets `_dirtyPanels.factions = true` etc.; `loadUI()` checks the flag before calling `renderFactionRep()`. Clears all flags on boot-load. 2. **Slice hash guards** — each render*() opens with `const _sig = JSON.stringify(relevantSlice); if (_sig === _lastSig) return; _lastSig = _sig;`. Zero changes to call sites. 3. **`autoImportState()` returns changed-categories** — loadUI() accepts an optional `changedSet` and only calls affected renders. Option 2 is the most surgical and testable (each function is independently guarded). Option 3 is the cleanest architectural change but requires more coordination. **Estimated impact**: For a typical AI response that changes 2–3 fields (loc, hpCur, inventory), ~18 of the 23 render*() calls would short-circuit. DOM mutation drops from ~23 batch updates to ~3–5. **File-touch list**: `js/ui-core.js` (loadUI), `js/ui-render.js` (each render*), `js/api.js` (optionally pass changedSet), `tests/check-persistence.js` + `tests/check-persistence.ps1` (render contract tests). --- ## 4. renderWorldMap() — Unconstrained Fuzzy Scoring `renderWorldMap()` (ui-render.js:1241) is called from `loadUI()` every time, regardless of active tab. ### What it does on every call 1. Reads all 36 zones from `FALLOUT_REGISTRY.zones` 2. Builds a visited `Set` from `state.locationHistory` (O(n)) 3. Builds a collected `Set` from `state.collectibles` (O(n)) 4. For **each of 36 zones**: - Runs `zoneHasUncollectedCollectible()` — iterates all 20–27 collectible defs + all 9 Lincoln items (O(m×k)) - If a current location exists, runs `scoreZoneForLoc()` — tokenizes and cross-matches the location string against all names in the zone's location list This is effectively O(zones × collectibles + zones × locations²) per `loadUI()` call. When the player is on the STAT or INV tab, this work is invisible — the map is not displayed. ### Proposed fix — BUILD-NOW Guard the `renderWorldMap()` call in `loadUI()` to skip when the DATA tab is not active: ```js // In loadUI(), replace the unconditional call: // renderWorldMap(); // With: const _dataTabActive = document.getElementById('tab-btn-data')?.classList.contains('active'); if (_dataTabActive) renderWorldMap(); ``` The DATA tab switch already calls `renderWorldMap()` (ui-core.js:877), so map correctness is fully preserved. The only case this changes: if `loadUI()` is called while the DATA tab is active (AI sync on the DATA tab), the map still re-renders — correct. **Verdict**: BUILD-NOW. Isolated, behavior-neutral (map still renders on tab switch and when DATA is active). Low test surface — one Suite 74 test assertion may need update if it assumes unconditional render. **Severity**: HIGH (eliminates the most expensive single render on every AI sync when not on DATA tab). **Files**: `js/ui-core.js` (loadUI guard), `tests/` (Suite 74 guard check). **[CACHE] [TEST]** required. --- ## 5. saveState() — Debounce Analysis ``` saveState() syncStateFromDom() ← ~20 getElementById() calls + 13 skill DOM reads clearTimeout(_saveTimer) _saveTimer = setTimeout(() => { if (_saveStr === _lastSaveStr) return; ← dirty check localStorage.setItem('robco_v8', _saveStr) }, 500) ``` **Assessment: Correctly implemented.** The 500ms debounce prevents write-storms during fast input. The dirty check (string comparison) prevents redundant writes. `beforeunload` flushes immediately. `syncStateFromDom()` is called synchronously on every `saveState()` — this means 20+ DOM reads happen on every keypress in a skill input. However, `getElementById` is O(1) in modern engines and these reads are cheap. **No action needed.** The only overhead worth noting: skill inputs call `oninput="saveState()"` which fires on every keystroke, triggering `syncStateFromDom()` + the debounce setup. The debounce correctly coalesces these. **No action needed.** --- ## 6. CSS Animations — Tab-Hidden Behavior ### Infinite animations in use | Animation | Element | Duration | Paused in standby? | |---|---|---|---| | `flicker 0.15s infinite` | `.crt-overlay` | 0.15s | ✅ Yes | | `refresh-bar 7s infinite` | `.crt-overlay::after` | 7s | ✅ Yes | | `header-pulse 3s infinite` | `.header h1` | 3s | ✅ Yes | | `limb-glitch 4.5s infinite` | `.limb-crip` | 4.5s | ❌ No | | `map-blink 1s infinite` | map markers | 1s | ❌ No | | `flicker-medium 0.1s infinite` | high-rad body | 0.1s | ❌ No | | `rad-shift` / `rad-chaos` | high-rad inputs | varies | ❌ No | | `update-banner-pulse 2s infinite` | update modal | 2s | ❌ No (but rare) | | `cursor-blink 0.6s infinite` | boot cursor | 0.6s | ❌ (gone after boot) | | `vats-scan 1s infinite` | VATS overlay | 1s | ❌ (only when VATS open) | **The three main ambient animations (CRT flicker, refresh bar, header pulse) are already correctly paused when the tab is hidden** via `body.standby` in terminal.css + `enterStandby()` in ui-core.js. The unpauseed animations (`limb-glitch`, `map-blink`, `rad-*`) only fire in specific states (crippled limbs, map visible, radiation > threshold). Their combined cost is low except in the `flicker-medium 0.1s infinite !important` case at very high rads — this fires 10 times/second. **Proposed fix — BUILD-NOW (low priority)** Extend `body.standby` CSS to also pause the state-conditional animations: ```css body.standby .limb-crip, body.standby [class*="rad-"] { animation-play-state: paused; } ``` **Note**: DIEGETIC_AUDIT.md (A0) also covers reduced-motion and CRT animation themes. This finding is performance-focused (tab-hidden CPU cost), not diegetic — they do not duplicate. **Verdict**: BUILD-NOW. Isolated CSS addition. No test needed. **Severity**: LOW (most users aren't in high-rad states; the main animations are already paused). **Files**: `css/terminal.css`. **[CACHE]** required. --- ## 7. Icon / Asset Weights ### icon.png — 196 KB The main PWA icon is 196 KB. This is the largest single file in the cache and is also referenced in `<link rel="icon">` and `<link rel="apple-touch-icon">` — it loads on every page visit. **Proposed fix — BUILD-NOW** Re-export at a lower color depth / higher PNG compression. The icon is a flat-color Fallout-style glyph — it should compress to <30 KB at the same resolution with optimal PNG settings. No visual change. **Verdict**: BUILD-NOW. Purely a binary asset replacement. Requires [CACHE] bump (icon.png is a served file). No test change. **Severity**: MEDIUM — 196 KB on every cold visit, every service worker install. **Files**: `icon.png`, `sw.js` (cache bump). ### Shortcut icons — 137.2 KB combined Four shortcut icons total 137.2 KB and are only consumed by the OS app launcher (never rendered in the browser). They could be 192×192 PNG at lower bit depth or converted to WebP with a PNG fallback. **Proposed fix — BUILD-NOW** (combine with icon.png compression pass). **Severity**: MEDIUM (same pass as icon.png). **Files**: `*.png` shortcut files, `sw.js`. ### Both DB + reg pairs precached `db_nv.js` (44.2 KB) + `db_fo3.js` (24.3 KB) + `reg_nv.js` (89.3 KB) + `reg_fo3.js` (45.9 KB) are all precached. Per session, only one DB + one reg executes (chosen by boot loader). The other pair sits in cache unused. **Assessment**: This is an intentional trade-off enabling fast game-context switching. A context switch reloads the page — if only the active pair were cached, a context switch would need a network fetch. **SKIP — the trade-off is correct.** --- ## 8. DOM Size and innerHTML Patterns ### index.html — 75.3 KB The entire UI is statically declared in index.html — all panels, all inputs, all tab content. No lazy injection. This is correct for a PWA targeting full offline use: the HTML is cached and serves as the app shell. **Assessment**: Lazy panel injection would reduce initial parse time but adds complexity and breaks the offline guarantee. **SKIP**. ### innerHTML assignment patterns | File | Count | Pattern | |---|---|---| | `ui-render.js` | 38 | All use `map().join('')` + single assignment — correct O(n) | | `ui-core.js` | 11 | Single assignments, no loops | No `innerHTML +=` inside loops found. The prohibited pattern (O(n²)) is not present. **No action needed.** ### Render cost by list size (worst case) | Render function | Max items | DOM nodes created | |---|---|---| | `renderFactionRep()` | 14 faction cards × ~7 elements | ~100 nodes | | `renderCollectibles()` | 27 items | ~54 nodes | | `renderWorldMap()` | 36 zone cells (overview) + N locations (detail) | ~100 nodes | | `renderInventory()` | unbounded | grows with playtime | | `renderCraft()` | 25 recipes + 12 breakdowns as `<option>` | ~80 nodes | | `renderSkillBooks()` | 13 per sub-panel | ~30 nodes | | `renderMagazines()` | 14 per sub-panel | ~30 nodes | None of these are inherently large, but rebuilding all of them on every AI sync is the multiplier effect described in §3. --- ## 9. Gemini / Network Call Efficiency `transmitMessage()` makes one `fetch()` to the Gemini API per user submission. There is no redundant call, no polling, and no memoization concern — the call is purely user-triggered. `fetchAuthorizedModels()` is a manual button click (`btnFetchModels`). No concern. `loadRemoteConfig()` (kill-switch) is called once at boot and not awaited (fire-and-forget). **Correctly implemented.** `autoImportState()` is called once per AI response — no duplication. **Assessment**: AI call efficiency is well-managed. **No action needed.** --- ## 10. Lazy-Load and Defer Candidates | Candidate | Verdict | Rationale | |---|---|---| | Static `<script src>` tags (`ui-*`, `api.js`) | SPEC-FIRST | Could add `defer` — functions are only called from `window.onload`, so deferred timing is safe. But Suite 56 has explicit load-order guards; any change to the script tag attributes needs test coverage. | | Dynamic boot scripts (`db_*.js`, `reg_*.js`) | SKIP | Already context-conditional; `async=false` is load-order-correct | | `cloud.js` | Already `type="module"` | Deferred natively — correctly implemented | | Panel content | SKIP | Must be present in the offline-first shell | | Chat history replay | BUILD-NOW (low) | Batch `appendToChat()` calls into a `DocumentFragment` instead of 200 individual DOM appends — eliminates 200 reflows at boot for long chat histories | --- ## 11. Prioritized Backlog ### BUILD-NOW — Safe, isolated, no spec risk | ID | Finding | Severity | Files | |---|---|---|---| | B-P1 | Guard `renderWorldMap()` in `loadUI()` — skip when DATA tab is not active | **HIGH** | `js/ui-core.js` · [CACHE][TEST] | | B-P2 | icon.png + shortcut icons: re-export with optimal PNG compression (196KB → ~30KB target) | **MEDIUM** | `icon.png`, `*.png` icons · [CACHE] | | B-P3 | Extend `body.standby` CSS to pause `limb-glitch`, `rad-*` animations when tab hidden | LOW | `css/terminal.css` · [CACHE] | | B-P4 | Batch chat history replay into `DocumentFragment` at boot | LOW | `js/ui-core.js` | | B-P5 | Remove duplicate `'./'` entry from `ASSETS` list in sw.js (or document it) | LOW | `sw.js` · [CACHE] | ### SPEC-FIRST — Require design doc before implementation | ID | Finding | Severity | Notes | |---|---|---|---| | S-P1 | **loadUI() dirty-diff rendering** — skip render*() calls when their state slice is unchanged | **HIGH** | The biggest win; touches all render functions. Requires agreed interface (option 1/2/3 from §3). Regression risk: any render*() that reads state indirectly must be audited. | | S-P2 | `defer` on static `<script src>` tags for UI modules | MEDIUM | Suite 56 has explicit load-order guards; test changes needed. Must verify `window.onload` still fires after all deferred scripts. | | S-P3 | `renderFactionRep()` dirty-flag guard (sub-case of S-P1) | MEDIUM | Can be done as a standalone slice of S-P1 — least risky entry point | ### SKIP | ID | Finding | Rationale | |---|---|---| | SK-1 | Both DB+reg pairs precached | Intentional: enables fast context switching | | SK-2 | Lazy panel injection | Breaks offline guarantee; complexity > benefit | | SK-3 | `saveState()` DOM reads | 500ms debounce + dirty-check make this negligible | | SK-4 | Gemini call dedup/memo | Calls are user-triggered; no redundancy | --- ## 12. Key Numbers Reference | Metric | Value | |---|---| | Total precache | ~978 KB | | Icon payload | 333 KB (34% of cache) | | JS executed before TTI (FNV) | ~428.5 KB | | render*() calls per loadUI() | 23 | | loadUI() call sites | 7 | | Infinite CSS animations | 10+ | | Animations paused in standby | 3 (crt-overlay, refresh-bar, header pulse) | | innerHTML assignments (ui-render.js) | 38 (all O(n), no `+=` in loops) | | saveState debounce | 500ms + dirty-check | | AI calls per user message | 1 | --- ## 13. Constraints Checklist (gates any future fix) - **[GATE]** Never `--no-verify`. 1078 tests must pass. - **[CACHE]** Any change to `index.html`, `css/`, `js/`, or icon files → bump `CACHE_NAME` in `sw.js`. - **[TEST]** New behavior → add tests to BOTH runners at parity (Protocol 2a, Protocol 15). - **[DATA]** All changes additive; no data-destructive modifications. - **[BEHAVIOR]** Performance changes must be behavior-neutral. The gate's test suite is the safety net. - **[VERSION]** Do not bump APP_VERSION for perf-only changes. Cache rev (`-rN`) bumps as needed. --- *End of PERFORMANCE_AUDIT.md*
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).