┌────────────────────────────── 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.
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.
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
Backgroundmust also be drained inBackground— 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.
processOOCQueuereleases at most one line peroocSendMinGap, measured against the previous actual send, and pollers consultoocQueuePendingbefore 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.
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)
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.
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.
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.
| 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*TexturePageagainst 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).
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 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:
- Identity is the server's base. Pack bytes are delivered under
deliverBase, never under the pack's own URL. - A pack hit never teaches the learned table. Pack formats have nothing to
do with the server's, and
RecordSuccess→RecordLearnedpersists. Teaching it from pack bytes is the v1.61.0 / v1.87.2 regression class. - 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. - A pack failure is never a server failure. (4a)
serveFromMounthas no error return, so a read error cannot reachwalkCandidates' pass-aborting default arm. (4b) A decode error quarantines the pack entry and never callsMarkFailed, 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. - 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.
- No mounts costs nothing.
activeMountLayerloads 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:BenchmarkFetchRawNoMountsvsBenchmarkFetchRawLayeredNoMountsis 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.
singleflight.DoChankeyed 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.
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.Transportin the repo setsProxy. A nilProxymeans never proxy, not "use the default", and ago/astcensus 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-Proxywhile the lobby and updater inheritedhttp.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
WinHttpGetProxyForUrlactually 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); macOSscutil --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.
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.
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.
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.
VScrollbaris the only drag-aware widget:Ctxtracks 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|_onart (its ownEmoteButtonasset type, WebP-first, Settings-toggleable) with the_offart + accent ring standing in while_onstreams. - 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-subsystemCREATE_NO_WINDOWchild couldn't reliably win foreground rights, so theOpenFileDialogopened behind the app or not at all). The browser is a modal overlay drawn last indrawSettingsbehind the samec.modalOnfence the emoji picker uses; it lists directories +.demo/.aorecfiles read by one bounded loader goroutine per navigation (never per-frameReadDiron the render thread), and a pick routes into the shared import tail (importDroppedRecording→importRecordingToVideo) — the same path drag-and-drop uses. The shell-out mechanism itself remains for the theme folder picker (browseForFolder, aFolderBrowserDialogthe 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 onCtx(§17.4 — no slice growth, no map, no per-frame alloc), andhovering()consults it aftermodalOn/clipOn. It is frame-scoped and self-healing (cleared inBeginFrame, so unlikemodalOnit 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 hardcodemenuBarHortabBarH. 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 inApp.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()andeditorDrawSiteRuns()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 (setFixedSizefromchar_select, default 714x668) and disagrees with thecourtroomrect on most real themes. Reusinga.themeRectswas 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 outsidecourtroom's bounds — which silently discardsspectatorand truncateschar_selectunder 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 onlyfitDesignCanvaswith the courtroom so both honour oneThemeFit. 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.
- 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 inBackgroundtoo (see "Wire producers are never gated on window state"). - The renderer sets
BLENDMODE_BLENDfor draw ops at startup: alpha fills (chat box, taken overlay, selection highlight) actually blend — SDL's default NONE silently rendered them opaque.
The courtroom's Wardrobe button opens a modal char-select-grade grid merging two sources, wardrobe first:
- The wardrobe — the user's own favourites, persisted in prefs
(
WardrobeCap1024) 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. <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 (iniswapListCap4096), case-insensitively deduped + sorted, lowercase names precomputed for the search filter; - icons are ordinary
AssetTypeCharIcontraffic: same paced demand (shareddemandAssetbudget/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→ bareX).
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.
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.
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 | 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. |