Skip to content

Latest commit

 

History

History
637 lines (561 loc) · 42.5 KB

File metadata and controls

637 lines (561 loc) · 42.5 KB

AsyncAO Architecture

Thread model

┌────────────────────────────── main / render thread (LockOSThread) ─────────────────────────────┐
│ SDL init → event poll → session reducer → courtroom Update(dt) → viewport anim clocks          │
│ → audio.Frame (chunk loads, pending plays) → pump.Frame (texture uploads, budgeted)            │
│ → destroy-queue drain (budgeted) → UI screens → Render → Present                               │
└───────────────▲───────────────────────▲──────────────────────────▲────────────────────────────┘
                │ decoded chan (64)      │ audio chan (64)          │ warning chan (32)
        ┌───────┴────────┐      ┌────────┴─────────┐      ┌─────────┴────────┐
        │ decode pool    │      │ asset manager    │      │ (same manager)   │
        │ max(2,NumCPU/2)│◄─────┤ tier walk on     │      └──────────────────┘
        │ magic sniffing │ jobs │ fetch pool       │
        └────────────────┘      │ workers (16, two │
                                │ lanes, epochs)   │
                                └───────▲──────────┘
                                        │ singleflight HTTP / local mounts
        other goroutines: WebSocket read loop → incoming chan (256, drained per frame)
                          prefs saver (debounced 250 ms, tmp+rename)
                          disk cache writer (bounded queue 256, tmp+rename)
                          1 Hz metrics sampler

Rule zero: only internal/render, internal/ui, cmd/asyncao touch SDL, and only on this thread. The decode pool outputs plain image.RGBA; texture creation, destruction (via the bounded destroy queue) and font rasterization all happen here.

Frame pacing & the event-driven loop

The main loop is not vsync-bound. Each pass computes a pacing budget from App.FramePace(focused) (the adaptive tier — full while interacting/animating, the idle rate on a static screen) and App.HardCapBudget(focused) (the INVIOLABLE active/background ceiling). The budget is slept in two tiers: the hard-cap floor UNINTERRUPTIBLY (so an input flood — mouse motion streams an event every few ms — can never bust the cap), then any surplus INTERRUPTIBLY (input during a slow idle tier renders within one ceiling instead of waiting the whole budget out).

With the event-driven loop (EventDrivenLoopOn, default ON) a static screen (SkipFrame true: no input, nothing animating, nobody talking) renders nothing and parks on sdl.WaitEventTimeout. It wakes on real input, on the cross-thread PushWake doorbell (a user event pushed by the WS read loop / decode delivery — the one sanctioned cross-thread SDL touch, wake.go), or when NextWakeDelay reports a real redraw deadline (idle-rate tick, caret flip, the next animation flip via Viewport.NextAnimDue). idle=off then means genuinely zero redraws until something changes; NoteDeadline / uiDirty / NoteAnimating are the "redraw one frame" hooks producers use so an off-thread change still appears at idle=0. NoteAnimating is a retrospective, self-clearing census: a clock-driven on-screen surface (animated theme chrome, a looping sprite preview, animated chatbox Text FX) marks it from its DRAW site every frame it moves, and SkipFrame / FramePace read the last frame's tally — never a bare state flag that can outlive its draw. Keying the skip on such a flag broke both ways (fixed v1.55.2): a sprite preview orphaned across a screen switch latched the pace ON with no draw left to clear it, and idle Text FX animated but reported nothing so the loop parked and froze it.

Audio is decoupled from the present rate. While the live courtroom is typing (App.AudioActive), the loop spends its pacing budget advancing the room — and playing its blips — at a fine ~60 Hz cadence via Background (room Update + audio drain, no draw), threaded through the SAME two-tier split-sleep; the next Frame draws the already-current room (roomPreAdvanced makes it skip its own room.Update). So blips play at their natural cadence even at a 1 fps cap, with the hard-cap floor still uninterruptible. SDL_mixer stays on this thread (rule zero) — there is no separate audio thread.

Wire producers are never gated on window state

App.Frame runs only when the window draws; App.Background runs while it is minimized (that branch continues before Frame is ever reached). So a producer that runs in Background while its consumer runs only in Frame accumulates for the whole occlusion and then flushes as one burst on the first restored frame. That is precisely how an idling minimized client disconnected itself: the live roster queued a /gas OOC command every 3 s from Background, while processOOCQueue — the drain — ran only in Frame. (That poll was deleted outright on 2026-08-08 — see KNOWN-ISSUES.md, "The automatic roster poll is gone" — so the class now has no producer left at all; the two invariants below still bind every remaining automated sender.) Servers count OOC per IP and kick on breach; the kick closes the socket synchronously while its explanation is still queued asynchronously, so the client sees a bare close carrying no reason at all.

Two invariants, either of which alone would have prevented it:

  • A queue whose producer can run in Background must also be drained in Background — so the send rate is identical in every window state, instead of being a function of whether the window happened to be on screen.
  • Every automated wire sender is paced at the DRAIN, not at queue time. Scheduling only spaces lines that are created apart; a backlog comes due all at once and leaves in one pass. processOOCQueue releases at most one line per oocSendMinGap, measured against the previous actual send, and pollers consult oocQueuePending before enqueuing so a slow drain cannot stack duplicates of the same command.

maybeFollowJump (internal/ui/follow.go) is the same shape still standing: it runs from Background, and jumpToArea sends an unpaced MC. It is safe only arithmetically — followJumpDebounce (2 s) is 0.5 packets/s against a typical server budget near 1.4/s, and the feature is opt-in — so anything added beside it inherits the hazard.

The frame clock is real time even while minimized. a.now() reads frameNow, and Background restamps it (1d759ce, first tagged v1.74.5) so timer-driven work — the music-await self-heal, the resume-seek elapsed math — is not wedged at the last foreground timestamp. The side effect is that every a.now()-debounced poller now genuinely fires with no window on screen. Before that commit the clock froze, a 3 s debounce could never elapse, and at most ONE /gas left per occlusion — which is why older builds could idle all day, and why this bug class stayed hidden. Anything that debounces off a.now() must be safe to run unattended.

A close code is not a diagnosis. Nyathena wraps every client in websocket.NetConn, whose Close() is hardcoded to Close(StatusNormalClosure, "") — so a kick, a ban and an ordinary cleanup all emit an identical 1000 with an empty reason. It means "some server code called close()" and nothing more. Four releases were spent reading intent into that string. Connection debugging starts on our side — what we sent, at what rate, from which loop — and instruments the client before it interprets the close.

Asset pipeline (spec §8)

Prefetch(base, type, prio)            PrefetchWithFallback(base, altBase, ...)
  └─ fetch pool job (epoch-tagged; room change cancels speculation)
       inflight dedup (one pass per primary base)
       T1 contains primary base? → done       ← textures key by BASE, so the
                                                 check is by base, pre-chain
       per base in {primary, alt}:
         resolver.BuildCandidates(base, type, host)
           learned hit → exactly 1 URL        ← atomic snapshot, no locks
           miss        → FormatList(type)     ← zero-fallback default = 1 format
         per candidate: T2 bytes?    → decode
                        T3 disk?     → promote to T2 + learn + decode
                        source fetch → T2 + async T3 + learn + decode
         every candidate 404 + learned was used → invalidate + one full-list retry
       still nothing → remember the exhausted CHAIN for the session (missSet)
                       → Warning{base, formats tried} → UI banner (12 s,
                                                        courtroom + char select)
       chain already exhausted → warn, submit nothing  ← the gate, above the pool
  decode pool: sniff magic bytes (never extensions) → RGBA frames (pooled px),
               animations DECIMATED to maxDecodedAssetBytes (T1 budget / 4,
               the cache.MaxDecodedAssetBytes single source of truth —
               keep evenly-spaced frames spanning the whole clip, fold skipped
               delays forward: a lower-fps full loop beats a truncated one that
               snaps mid-preanim, and both dodge a 250 MB RGBA spike inside the
               256 MiB process budget); fixed-cell types
               (char icons → 64 px, emote buttons → 40 px) thumbnail at
               decode, so a 500×500 pack icon costs ~16 KB of T1 instead of
               ~1 MB and a 4000-char roster fits the texture budget whole
  render pump: live-message uploads immediate; speculative ≤ 16 textures /
               4 MiB per frame (bytes protect 16 ms; the count just bounds
               tiny-upload bursts). A page the LRU refuses is destroyed +
               reported, never leaked.
  audio types skip decode entirely: bytes → SDL_mixer (C decodes opus/ogg/mp3/wav)

Sprite name chain (AO2-Client CharLayer::load_image)

Packs ship idle/talk sprites as (a)<emote>/(b)<emote> or as bare <emote> files (1.webp, 2.webp, …). Courtroom.begin therefore uses PrefetchWithFallback(prefixed, bare): the bare spelling is probed only after every format of the prefixed one 404s, the asset keeps the prefixed base as its identity (scene layers, T1 key), the 404 cache stops the extra probe from repeating inside its TTL, and once resident the T1 short-circuit costs zero probes. Extension learning is unaffected — whichever spelling hits records the host's format as usual.

Demand-driven loading (visible = demand, not speculation)

Connect-time bursts are capped (charIconWarmup = 128): a 4000-character server would only shed itself out of the 256-slot low lane. Instead, the char grid and the emote picker demand exactly what is on screen: at most charIconAskPerFrame (32) submissions per frame from a shared budget, one re-ask per asset per charIconRetryInterval (2 s) — shed jobs are never re-run by the pool, so the cadence self-heals backpressure, and loaded textures stop asking via the store lookup that precedes every ask. The live scene gets the same treatment at HIGH priority (healScenery): an evicted background/desk re-demands on the same cadence, and the viewport holds the last-resident scenery (syncAnimSticky) until the replacement texture actually lands — a position flip never blanks the viewport.

Hovering any character cell (either grid, the wardrobe too) warms its char.ini through the decode-free raw lane (Manager.PrefetchRaw: pool-bounded, inflight-deduped, T2 + disk), so the eventual pick loads its emote list from memory instead of paying an RTT.

Conclusive misses (the cadence has to terminate)

The demand loop above only ever ended on T1 residency, and an asset the server does not have can never become resident. A roster whose characters ship no char_icon and no emotions/button<N>_off art therefore re-asked for every one of them every 2 s for as long as the grid was on screen.

Manager.conclusiveMiss (assets/missSet, cap conclusiveMissCap = 8192) records the resolution chain — base + type + the alt spellings probed after it — the first time every candidate 404s. Every Prefetch* entry point consults it before submitting; a hit warns and returns without a pool job. demandAsset consults it too and reports "this can never arrive", which is what lets a permanently blank cell stop holding the frame pump awake.

The network client's 404 cache does not and cannot do this job. It is 1024 entries at a 5 min TTL, so a large roster evicts its way back onto the wire, and it sits at the BOTTOM of the pipeline — a hit there still costs the pool job, the resolver walk and two failed disk reads above it. That overhead, not the requests, is what took the framerate down.

The memory has no clock, because it is a fact about work already done rather than a guess with an expiry (contrast LocalFetcher.missMemo, which is the guess). What invalidates it is a change to either input of that fact — the byte source, or the candidate list it was measured against — so the flushes live inside the setters that make those changes and no call site can forget them:

Event Scope
config.FormatGeneration moves (format order, fallback toggles) all
installAssetOrigin (connect / reconnect / mounts change) that origin
SetMountLayer (pack rescan) all
SetLocalOverlay with a different mount set all
Settings > Cache > Retry missing assets all

The first row is not a call at all: every entry carries the format generation it was recorded under, and any operation that reads or extends the set retires a stale one first. It has to be structural rather than a flush call, because adding the server's real format in Settings > Assets is the FIRST thing a user does when sprites look missing — and this gate sits above the resolver, so a memory that ignored the change would make that setting do nothing, silently and for the rest of the session.

A transport error is never recorded: walkCandidates reports it and ends the pass before the exhaustion path, so a timeout or a 5xx leaves the asset probeable. Only "every candidate returned 404" is a finding — and not even that in rehearsal mode, where the offline gate manufactures the 404s.

Cache tiers (§9)

Tier Holds Budget Keying Eviction
T1 *sdl.Texture pages + frame timing 64 MiB (Σ w×h×4) asset base byte-budget LRU → destroy queue on render thread
T2 raw fetched bytes 128 MiB full URL byte-budget LRU
T3 disk blobs unbounded, user-clearable xxhash64(full URL), sharded assets/<xx>/<hash> manual / Clear button

Full-URL keys make per-server separation structural: two servers (or two local mount sets — their origin embeds a mount-list hash) can never collide.

Two generation counters keep hot paths lock-free without staleness:

  • AssetPreferences.FormatGeneration — bumped by format mutators; the resolver's miss path serves probe lists from an atomic per-generation snapshot (70 ns/op, 1 alloc — identical to the learned path).
  • TextureStore.Generation — bumped on upload/eviction/purge; each viewport layer caches its *TexturePage against it, so steady-state rendering does zero LRU operations and a cached pointer can never outlive its textures (destroys happen later in the same frame, after the generation check).

Resolution engine (§6)

learnedTable is an immutable map[host]*[AssetTypeCount]string behind an atomic.Pointer. Reads: one load + map index + array index (~68 ns, 1 alloc — the joined URL). Writes: copy-on-write + CAS retry loop; a successful learn marks preferences dirty for the debounced saver. Learned entries persist per <host>|<type> and survive restarts (warm start = N probes for N assets).

Local pack layer (MountIndex / MountLayer, v1.89.0)

Local mount folders and .zip packs answer asset fetches before the network, under the server's own URL. The server's URL stays the asset's identity everywhere (T1 key, warnings, scene layers); local://m-<hash>/<rel> is only a transport label, so nothing downstream ever learns a second spelling.

Six invariants. Check each commit against them:

  1. Identity is the server's base. Pack bytes are delivered under deliverBase, never under the pack's own URL.
  2. A pack hit never teaches the learned table. Pack formats have nothing to do with the server's, and RecordSuccessRecordLearned persists. Teaching it from pack bytes is the v1.61.0 / v1.87.2 regression class.
  3. A pack hit never writes T3 or ThumbCache, and never writes T2 under the server's URL. It may write T2 under its own local:// key — that keyspace is disjoint, and T1 evicts long before T2, so without it every evicted pack background would be re-read and re-allocated from disk. ThumbCache is a persistent disk cache keyed by base, so a pack sprite there would outlive the pack under the server's identity.
  4. A pack failure is never a server failure. (4a) serveFromMount has no error return, so a read error cannot reach walkCandidates' pass-aborting default arm. (4b) A decode error quarantines the pack entry and never calls MarkFailed, whose key is the SERVER's base. (4c) The asymmetry is deliberate: a read error is environmental and usually transient, a decode error is a deterministic property of the bytes.
  5. The layer is inert during archive replay and in Local-only mode. A bundled scene must stay hermetic; Local-only's source already is the mounts, so layering would double-serve.
  6. No mounts costs nothing. activeMountLayer loads the layer pointer first and returns on nil, so the default path is one atomic load (benchmarked at ~0.9 ns/op, 0 allocs). No index, no goroutine, no disk. Benchmark the hook and the call that rides it: BenchmarkFetchRawNoMounts vs BenchmarkFetchRawLayeredNoMounts is the pair for the text lane (~3 ns on a ~50 ns read, 0 extra allocs), because timing the hook alone cannot answer what a real reader pays.

The text lane (issue #72). A mounted base answers char.ini and effects.ini, not only the art beside them. FetchRawLayered is the text sibling of ResolveRawLayered: pack first, server second, and named apart from FetchRaw so the callers that must NOT see a pack keep the unlayered method by default. Those are extensions.json (it seeds the server host's learned formats — invariant 2) and the autoindex listings, both already refused by mountLayerExcluded. The pack is consulted before T2, like the exact decode path, so a server copy cached earlier in the session cannot shadow the file the user just edited. Pack bytes are returned uncached: a couple of KiB read once per character per session, where a cache would only add a way to serve a stale ini back after a rescan. PrefetchRaw has the matching early-out — a file the pack holds has no RTT to hide, so warming it would spend a real probe, and a remembered miss, on a URL the server need not have at all.

Art from one base and metadata from another is not half a feature; it is a character that looks right and behaves like somebody else's. A new raw-text fetch therefore fails a census gate by default and its author has to name which side it means, because a call site reaching for the server-only lane is invisible: FetchRaw returns perfectly good bytes, just the wrong side's.

A source change invalidates parsed inis, not only textures. An ini is parsed into UI state keyed by URL, in maps no texture invalidation touches, so rescanLocalPacks calls forgetParsedINIs ahead of the texture-store guard — parsed inis are not textures, and gating them on a store existing leaves the headless path silently half-invalidated. It reloads the active character's ini in place rather than rebuilding the room, because buildRoom re-arms the roster poll whose OOC /gas is the known flood-kick vector.

Setup surface. internal/ui/basescan.go reads a candidate folder (counts, a bounded char.ini sample, the standard subfolders, and the two off-by-one-level corrections) and basewizard.go reports it before anything is written. applyBaseWizard is the only writer in that flow, pinned by a census gate: the report is only worth reading if browsing, scanning and re-picking cannot change what the user is currently running. The scan runs off the render thread behind a single-flight latch (hard rule 2), and its result carries the path it scanned so a superseded pick is reconciled on landing rather than queued.

One key space. Every map and set is keyed by the folded rel including its extension (foldRel: per-segment percent-decode, then lowercase, returning the input unchanged when a byte scan finds nothing to fold). A pack transport URL is LocalOrigin() + <that key>, so URL→key is a CutPrefix and key→URL a concatenation. Two earlier drafts shipped a quarantine that was dead code because it was written with a URL and read with a rel.

Ladder order, not provider-major. The pack is consulted first within each spelling, not swept across every spelling first. EmoteBare takes no EmoteKind, so the (a) and (b) chains share a byte-identical bare alt — provider-major ordering would let a legacy bare-named pack serve one file as both idle and talk, shadowing a server that ships proper (a)/(b) art. Do not "fix" this back to AO-SDL's ordering.

Named caps: mountIndexByteCap (accounted footprint, enforced during the walk, sized against real headroom — a hard 256 MiB SetMemoryLimit with 128 MiB already committed to T2), mountIndexMaxDepth, mountBadCap (quarantine; eviction is oldest-first, inverting MarkMissing's stop-when-full policy, because an unquarantined corrupt file wins forever and its asset is permanently missingno), mountArchiveCap, mountZipEntryMaxBytes, LocalMountCap, mountLayerOriginCap.

Zip lifetime. Archive handles are refcounted and closed when the last reader releases, not when the layer pointer swaps — a Rescan during a read would otherwise close the file mid-ReadAt. Symlinks are never indexed (a pack could otherwise ship a link at a plausible asset path pointing at a private file); zip entry names get the same escape guard, and the uncompressed size in the header is treated as the attacker-controlled hint it is.

No new module dependency: archive/zip is stdlib.

Network (§7)

  • singleflight.DoChan keyed by URL — concurrent identical fetches share one upstream call; a caller's context cancels only that caller's wait.
  • Negative cache: expirable LRU (1024 entries / 5 min). Cached 404s never touch the wire. It is the last line, not the first: the pipeline stops a known-absent asset at the Manager (see Conclusive misses) so a repeat demand costs no pool job either.
  • Transport: 16 conns/host, 8 idle, 90 s idle timeout, compression off (assets are pre-compressed), TLS session cache, 2 s TLS handshake cap. HTTP/2 engages automatically on https hosts; plain-http AO hosts ride tuned HTTP/1.1 keep-alive.
  • DNS pre-resolve at server connect + lazy 5 min refresh inside the dialer — skipped entirely when a proxy would carry that host. Behind a proxy the transport dials the proxy and the proxy resolves the origin, so warming the local cache would publish the server's name to this machine's resolver for no benefit. Privacy property, not an optimisation.
  • Per-host exponential backoff (500 ms → 30 s) on transport failure.

Proxy (internal/netproxy)

One immutable Policy published through an atomic.Pointer, read by every dial without a lock; a refresh builds a new one and swaps. Resolved at boot (before sdl.Init, so a registry read or a WPAD round trip costs boot latency and never frames) and on an explicit Settings change — never per request (hard rule 2).

  • Every http.Transport in the repo sets Proxy. A nil Proxy means never proxy, not "use the default", and a go/ast census over the tree fails the build gate on any literal without it. Before that gate, the asset transport and the game socket were both nil-Proxy while the lobby and updater inherited http.DefaultTransport — a split tunnel worse than either extreme.
  • Order inside "use the system's setting": environment first, OS second. An env var is an explicit per-launch instruction; the OS setting is ambient.
  • Discovery: Windows registry + WinHTTP (with WinHttpGetProxyForUrl actually resolving WPAD/PAC rather than assuming — the auto-detect flag is ON by default on machines with no proxy at all, so assuming would strand them); macOS scutil --proxy; elsewhere env only.
  • Fail closed when a proxy is configured but its destination is unknowable (a PAC script off Windows). Falling back to direct is the leak this exists to close. Bypassed destinations are exempt — we know where those go.
  • Bypass matching is hand-written; see the dependency table for the measurements that rule out every library alternative.
  • Fetch pool: 16 workers (fetches are RTT-bound; the transport is sized for 16 conns/host and h2 bases multiplex them over one connection — spec §7's original 8 halved cold-viewport fill for nothing), HIGH lane (live message — blocks producer briefly, never sheds) and LOW lane (speculation — sheds oldest job when full). Epoch counter cancels queued jobs on room/server change; cancelled jobs still get Run(stale=true) so no waiter hangs.

Known-length reads (documented deviation)

spec §7 suggested pooled read buffers. Payloads are retained indefinitely by T2/T3, so a pooled buffer could never return to its pool — pooling would add one copy and zero reuse. Known-length responses therefore read with a single exact-size allocation + io.ReadFull (no growth, no copy); unknown lengths accumulate in a pooled scratch buffer copied out once.

Protocol (§ + AO2-Client 2.11)

WebSocket text frames; HEADER#field#...#% with <num>/<percent>/<dollar>/<and> escaping. Fast-loading handshake only — and loading is client-initiated: decryptor→HI, ID→ID, FL, PN→askchaa, SI→RC, SC→RM, SM→RD, DONE (without askchaa every server waits forever; only the askchar2 paging is legacy). MS parsing honors MS_MINIMUM=15, gates fields ≥ 15 on cccc_ic_support, normalizes legacy emote mods, and parses pairing (id^order, x&y offsets) with AO2-Client's exact z-order semantics (^0 = speaker in front). Outgoing MS reproduces AO2-Client's feature-gating ladder and its asymmetry (the server injects partner fields when relaying).

Outbound automation carries its own named ceilings, independent of the frame rate: oocSendMinGap (1 s) is the hard floor between two automated OOC lines — login flows, macros and the live-roster poll share one queue — and macroQueueCap (32) bounds the pending backlog (rule §17.4). Both are enforced where the queue drains, for the reasons above.

UI kit contract (internal/ui)

Immediate-mode over one per-frame input snapshot — order is law: BeginFrame (clears the snapshot) → HandleEvent per polled SDL event → draw pass reads it. Feeding events before BeginFrame erases every click before any widget sees it (this shipped once; TestInputSnapshotOrder pins the contract). Mouse coordinates refresh from motion/button events, so a release hit-tests where it actually happened.

  • Clicks fire on left-button release over the widget.
  • Clipboard: Ctrl+V appends (flattened to one line), Ctrl+C copies, Ctrl+X cuts — focused text field only; SDL keeps control chords out of TEXTINPUT so nothing double-inserts.
  • VScrollbar is the only drag-aware widget: Ctx tracks the held left button plus a drag-owner id, pressing the track centers the thumb there (one click to the bottom of a 4000-char list), and its return value clamps wheel scrolling to content.
  • HoverPreview (3 s dwell, right-click instant) pops the full sprite: char select previews idle, the emote picker previews the TALKING (b) sprite — what plays when the message sends.
  • Emote buttons draw emotions/button<N>_off|_on art (its own EmoteButton asset type, WebP-first, Settings-toggleable) with the _off art + accent ring standing in while _on streams.
  • Ctrl+A arms select-all on the focused field: the next typed/pasted text replaces the whole value, backspace clears it, Ctrl+C/X act on everything; a highlight shows while armed.
  • Screens never do disk I/O on the render thread: theme-folder scans, char.ini fetches, the native folder picker (Browse → PowerShell FolderBrowserDialog) and dropped-path resolution (SDL DROPFILE) all run on goroutines and land via polled channels, like the lobby fetch.
  • The Studio tab's ".demo → video" Import button opens an in-app file browser (demobrowser.go) on every OS — the demo picker moved in-app after the native dialog's foreground fragility failed live (a GUI-subsystem CREATE_NO_WINDOW child couldn't reliably win foreground rights, so the OpenFileDialog opened behind the app or not at all). The browser is a modal overlay drawn last in drawSettings behind the same c.modalOn fence the emoji picker uses; it lists directories + .demo/ .aorec files read by one bounded loader goroutine per navigation (never per-frame ReadDir on the render thread), and a pick routes into the shared import tail (importDroppedRecordingimportRecordingToVideo) — the same path drag-and-drop uses. The shell-out mechanism itself remains for the theme folder picker (browseForFolder, a FolderBrowserDialog the in-app browser doesn't replace today); the in-app browser is the proven escalation path if that one ever fails foreground the same way.
  • Overlay fences make a single-pass kit occlusion-aware (overlayfence.go). Because the pass is immediate-mode, a widget drawn early cannot know a widget drawn later will paint over it — so chrome clicks leaked through to the theme underneath. The fix follows the house idiom of publishing a rect others consult (c.modalOn, c.ddOpenList, a.boxFencesPointer) rather than inventing a paradigm: overlays register the rect they actually paint in a bounded fixed-size registry on Ctx (§17.4 — no slice growth, no map, no per-frame alloc), and hovering() consults it after modalOn / clipOn. It is frame-scoped and self-healing (cleared in BeginFrame, so unlike modalOn it can never strand and freeze the UI), pass-scoped so a later overlay doesn't inherit an earlier one's fence, and mark-scoped for the occluder's own hit tests so fences already in force still apply to it — which is what lets the registry nest for the menu bar's panes and submenus. An occluder whose position depends on a latched flag must publish inside the pass that sets the flag, never from a site that also runs on frames where that pass didn't draw.
  • One reserved top chrome band, measured in one place (chrometop.go). topChromeH() = menu bar (menubar.go) + the docked server-tab strip (tabs.go), and every screen offsets its content by it — no screen may hardcode menuBarH or tabBarH. Both halves self-zero (the menu bar in the full-window modes that preempt the screen dispatch — gif export, replay, scene maker, theater; the strip when there are no sessions or the user has dragged it out of the band), so a screen never has to ask which chrome is showing. The strip has exactly two draw sites — inside the courtroom pass, beneath a layout editor's banner, or over everything in App.Frame — arbitrated by a latch taken once at the top of the pass, because re-deriving the predicate at each site let them double-paint on the dismiss frame and drop the paint on the arm frame. screenDispatchPreempted() and editorDrawSiteRuns() are the shared predicates: the arrow-key nudge (layoutnudge.go) claims a key only when an armed editor's own draw really reaches the screen, using the same conjunction the strip's paint site does, so keyboard and paint can never disagree about who owns the frame.
  • Character select owns a second design canvas, deliberately outside a.themeRects (charselectlayout.go, charselectgrid.go, charselectwidgets.go). AO2's char select is its own fixed-size canvas (setFixedSize from char_select, default 714x668) and disagrees with the courtroom rect on most real themes. Reusing a.themeRects was blocked twice over: every key there becomes a draggable, persistable box in the courtroom layout editor, and the courtroom design space drops or clamps any key outside courtroom's bounds — which silently discards spectator and truncates char_select under the stock theme. So the screen keeps its own rect map with the stock AO2 table as a per-key fallback (AO2 falls back to its default theme key by key, not file by file), and shares only fitDesignCanvas with the courtroom so both honour one ThemeFit. Widget rects take the canvas scale; the character grid maps each cell through the canvas rect, because a scaled cell plus a scaled gutter round independently and the error accumulates across a row.

Courtroom knobs (all persisted, all live)

  • View − + resizes the viewport (40–85 % of the window width; default 66 ≈ the original 2/3) — log column and chat box reflow.
  • Text − + zooms the IC message box (100–250 %): a dedicated scaled font slot in Ctx, raster invalidates on zoom/width change, box height grows with the zoom.
  • Log − + scales log/OOC/music/area list text (75–200 %); the label cache keys by font identity so scaled labels cache like any other.
  • Box − + scales the IC/OOC input field height (75–200 %).
  • OOC tab (Log | Music | Areas | OOC): full scrollable OOC history plus the IC showname (live — outgoing messages read it per send) and the permanent OOC name, both persisted.
  • Volumes (Settings): music/SFX/blip 0–100, applied via SDL_mixer — music globally, chunks per playing channel.
  • Format order (Settings): ticking picks the probe set, clicking an order chip promotes that extension one slot toward "probed first".
  • The pairing panel picks partners from a searchable click-to-pick list (the old one-by-one cycle was unusable against 4000-char rosters).
  • While minimized the loop runs App.Background (session pump plus the OOC automation drain, no drawing) at a 50 ms nap — keepalives keep flowing at ~0 % GPU, and queued automation keeps leaving at its paced rate rather than piling up for the restore. The drain there is load-bearing, not incidental: its producers run in Background too (see "Wire producers are never gated on window state").
  • The renderer sets BLENDMODE_BLEND for draw ops at startup: alpha fills (chat box, taken overlay, selection highlight) actually blend — SDL's default NONE silently rendered them opaque.

Wardrobe & iniswap (custom characters)

The courtroom's Wardrobe button opens a modal char-select-grade grid merging two sources, wardrobe first:

  1. The wardrobe — the user's own favourites, persisted in prefs (WardrobeCap 1024) across sessions and across servers (folder names are server-agnostic; assets resolve against the current origin). Stars on each cell toggle membership; an add box accepts any folder name, so no server list is required at all.
  2. <asset origin>/iniswap.txt — one character folder per line — the server-curated set, merged underneath minus wardrobe duplicates (case-insensitive).

Neither occupies a server slot. Every layer reuses the existing fast path, nothing bespoke:

  • the txt rides FetchRaw (T2 + disk cached, singleflight) on a goroutine; parse is bounded (iniswapListCap 4096), case-insensitively deduped + sorted, lowercase names precomputed for the search filter;
  • icons are ordinary AssetTypeCharIcon traffic: same paced demand (shared demandAsset budget/cadence), same 64 px decode thumbnails, same 404 cache — the list-character pipeline is untouched, the menu is just a second consumer of it;
  • hover previews and, once picked, live sprites go through the normal name-chain ((a)X/(b)X → bare X).

Picking an entry only swaps the active folder: outgoing MS carries the custom name in char_name (AO2-Client set_iniswap semantics — servers relay the folder, receivers stream it like any speaker), and the emote list reloads from the custom char.ini. Re-picking a list character or disconnecting clears the override; an in-flight txt fetch is drained on disconnect so a stale list can't land after reconnecting elsewhere.

Lobby data

The master list JSON is parsed in full — ip, ports, players, name and description. Starring a server persists name + URL + description into the phone book (config.FavoriteServer), and MergeFavorites synthesizes entries for private servers so the lobby shows their descriptions even with the master list unreachable. The live master description wins for servers still listed.

Pairing fast path (§11)

Courtroom.begin prefetches the speaker's idle/talk/preanim AND the pair partner's idle sprite at HIGH priority in the same instant; the pool runs them on parallel workers and singleflight dedups any overlap, so paired cold load ≈ single cold load (test-gated). Render draws pair layers by SpeakerInFront, offsets as percent of viewport, flips via RendererFlip — no extra cost over a solo sprite.

Dependency justifications (§2 + additions)

Dependency Why
veandco/go-sdl2 SDL2/ttf/mixer bindings (the stack the references use)
hashicorp/golang-lru/v2 (+expirable) thread-safe LRU; wrapped only for byte accounting
golang.org/x/sync singleflight fetch dedup (pinned v0.17.0 for Go 1.24)
cespare/xxhash/v2 fast non-crypto cache keys
kettek/apng APNG decode (the draft's pick was a diff library!)
golang.org/x/image pure-Go WebP fallback + embedded Go font
coder/websocket addition: AO2 ≥ 2.11 is WebSocket-only; stdlib has no WS client. Zero-dependency, maintained, context-aware.
MSYS2 libavif (CGO, no Go module) addition (user request): .avif as a probe format — native dav1d/aom decode for stills and AV1 image sequences, bound exactly like the libwebp CGO shim (~100 lines). The pure-Go alternatives embed a WASM runtime (gen2brain/avif → wazero), the opposite of this project's soul. CGO-less builds degrade to a descriptive decode error; sniffing (ftyp + avif/avis brands) stays pure Go.
System ffmpeg (runtime, no Go module, no CGO) addition (user request, #99 scene video export): the 🎥 Video button streams captured frames into an external ffmpeg process (internal/videoenc, pure Go) for H.264/MP4 or VP9/WebM. It is runtime-optional by design (a user requirement: the client must still boot without ffmpeg installed) — Available() is a cached PATH lookup, a missing ffmpeg only disables that one action, and there is no build-time dependency and nothing linked. Audio (music/SFX) muxing into the video is reserved for a follow-up pass.
OpenDyslexic font (embedded asset, no Go module) addition (user request, M9): the "dyslexia-friendly font" toggle bundles internal/ui/fonts/OpenDyslexic-Regular.otf via //go:embed (~172 KB) so it works for every user with no separate install — a path-on-disk preset only helped the few who'd installed it. SIL OFL 1.1 (license shipped alongside as OpenDyslexic-LICENSE-OFL.txt; embedded unmodified, so the Reserved Font Name clause is satisfied). Applied only to the IC/OOC chat + log text (the existing override-chain scope), so chrome widget metrics are untouched.
Proxy support — NO dependency added (internal/netproxy, stdlib only) rejection record, because this rule exists to capture rejections as much as additions. Honouring the OS proxy needs three things: OS discovery, a bypass matcher, and SOCKS. SOCKS is free — net/http dials http, https, socks5 and socks5h natively. Discovery is free too: stdlib syscall on Windows exports RegOpenKeyEx/RegQueryValueEx/RegCloseKey/HKEY_*, and WinHTTP is reached through kernel32's LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32); macOS uses os/exec + scutil; Linux has no OS setting to read. golang.org/x/net and golang.org/x/sys are NOT in go.mod — either would be a brand-new direct require, so neither is "already there". Rejected candidates, all permissive and therefore AGPLv3-compatible (licence was never the deciding factor): x/net/http/httpproxy — the stdlib copy is vendored and unimportable, and it is measurably wrong for this job (against a real Windows ProxyOverride, <local> is inexpressible and 10.*/172.16.*/192.168.* can never match an IP literal, so a LAN AO server silently goes to the proxy); mattn/go-ieproxy — needs cgo on macOS, so a CGO_ENABLED=0 mac build silently reports "no proxy", is a no-op on Linux, pulls three x/ modules, and ships that same broken bypass translation; rapid7/go-get-proxied — cleanest of the bunch, but its API is a per-call GetProxy() with a fresh WinHTTP round trip each time, which cannot sit on an asset transport under hard rule 2, so it would have to be wrapped in the snapshot we are writing anyway; any PAC-evaluating library (darren/gpac, saucelabs/pacman) — each embeds a JavaScript VM (otto/goja) against a 256 MiB budget, to do what WinHttpGetProxyForUrl does in-process for free on Windows; libproxy — LGPL C library, so cgo plus a runtime .so plus relinking obligations, for a Linux case that env vars already solve.
josephspurrier/goversioninfo (BUILD-TIME tool, NOT a linked dependency) addition (Defender false-positive mitigation): generates the committed Windows VERSIONINFO resource (cmd/asyncao/versioninfo_windows.syso from versioninfo.json) that gives the .exe real CompanyName/ProductName/FileDescription/LegalCopyright metadata. A blank-provenance unsigned Go binary is a small heuristic signal in the Bearfoos.A!ml false positive (docs/DEFENDER-FALSE-POSITIVE.md); the resource lowers that surface at zero runtime cost. Run via go run …@v1.4.0 (the same pin as .github/workflows/release.yml; never imported — it does not enter go.mod and nothing links it); the syso is committed so a normal build needs no tooling, and the release workflow regenerates it per-tag best-effort.