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).