Brodgar client

Cross-cutting client services

Keybindings, resources, buffs, the vitals bars, the speed selector, the belt, the action menu, minimap icons, crafting and equipment. Seven subjects have a page of their own: audio.md, character-sheet.md, combat.md, kin-window.md, chat.md, console.md and prefs-and-options.md.

Service Where  
Keybinding registry (remappable, persisted) KeyBinding.get (id → binding, process-global), set, key(), all() (fork addition), Bindable; stored as keybind/<id>. get(id) with no default is a pure lookup, null for an id nobody minted; get(id, defkey) mints. ⚠️ A binding can leave the registry: unregister(id) (fork addition) drops it with its claim and its stored assignment, for a binding the client mints on demand and then retires (an action-menu entry’s scm/… id), so something holding the KeyBinding object asks get(id) == binding before acting on it; the client’s own static bindings are never unregistered. unlisted(id, defkey) (fork addition) builds one that never enters the registry — no all() row, no claim, no stored assignment — for a PagButton that must carry a bind (its constructor reads binding()) but stands on no screen whose keys reach it. The key field is three-state — null means “use defkey” (what key() collapses), KeyMatch.nil means explicitly unbound, anything else is the assignment. ⚠️ An assignment outlives the defkey it was copied from: key() consults defkey only while key is null, so changing a binding’s default in the source does not reach a profile that has ever assigned that id — including one assigned by a caller restoring what it read, since what it read may itself have come from the old default. Only set(null) (the panel’s Backspace, “revert to default”) puts a binding back under its defkey; Utils.getpref("keybind/<id>", "<unset>") is how you tell the two apart from outside. ⚠️ A key is held by ONE binding, and the fork enforces it three different ways (fork addition): assigning a real key claims it in the static claimed map, keyed by keyid (keycode + modmatch, keycode normalising a forchar and a forcode match to one code), and that claim clear()s whichever other binding had assigned the same key. A binding still on its defkey is never written to: key() reads the claim live and answers KeyMatch.nil while someone else holds its default, then hands the default straight back the moment that claim is dropped. So a default that loses its key recovers by itself, and only an assignment can be destroyed by another assignment. The third way is at match time, and it is there because the claim is exact while a KeyMatch need not be: key() hands out a Yielding subclass whose match re-checks claimed against the event’s own keycode + modifiers, less whatever modign the caller passed (that being the caller declaring those modifiers no part of the key’s identity — Fightsess.keyup passes MODS to ask “was this very key released?”; MenuGrid passes nothing). ⚠️ The action-menu hotkeys ignore Shift on purpose — PagButton.hotkey() is forchar(hk, MODS & ~S, 0) and MenuGrid.globtype reads the Shift bit as keep the menu open — so plain B also answered Shift+B, and since MenuGrid is above the addon root in the globtype walk it ate an assigned Shift+B one menu level per press. The wrapper is cached per binding: OptWnd.SetButton.follow (which its draw runs every frame) compares key() by identity, so a fresh object per call re-labels the button every frame. get claims for an assignment as it restores it but runs no exclusivity pass of its own, so two defaults may share a key and both fire — which is exactly what the action-menu hotkeys (scm/<res>, one per pagina, defaulted from the resource’s own AButton.hk) do among themselves at different menu levels, harmlessly. A caller that saves-and-restores a key writes an assignment either way, so it can still take a key off another assignment — check all() first — and a binding added by the fork ships unbound (KeyBinding.get(id, KeyMatch.nil)) rather than claiming a default that any assignment on the same key would shadow. ⚠️ A claim can outlive whatever answers the key, and hold()/release() is the pair that fixes that (fork addition): release() drops a binding’s claim while leaving its assignment and its pref untouched, so the key answers to everyone else again; hold() takes the claim back for an assignment that already exists and never steals — where the key went to someone else in between it clear()s its own assignment instead, so the panel never shows a key that cannot fire. Together they are how a binding whose owner comes and goes stops holding a key nothing can answer, without the owner’s absence costing the user their remap. ⚠️ No modifier can be bound to anything, and the refusal that stops it is load-bearing: KeyMatch.Capture (which OptWnd.SetButton extends) closes its key grab the instant handle answers true, and handle answers false for VK_SHIFT/VK_CONTROL/VK_ALT/VK_META/VK_WINDOWS — which is exactly what holds the grab open across the VK_CONTROL event of a Ctrl+M so the VK_M after it can be captured at all. Lift it and every chord in the client becomes unassignable, so a hold-while-dragging gesture cannot be a binding and stays hard-wired to a modifier. KeyMatch.ModCapture answers the same problem the other way (accumulate on keydown, commit on keyup) and nothing in the tree uses it; PointBind carries its own copy of the refusal  
Resource system (classpath / local dir / server) Resource: local(), remote(), FileSource/JarSource (/); local dir via haven.resdir/HAFEN_RESDIR  
Resource-code adoption java -cp bin/hafen.jar haven.Resource get-code <res> + @FromResource (name+version must match — ResClassLoader.loadClass); haven.Resource find-updates src checks the pins. “Which resource is this class from?” — Resource.classres answers but is not a cheap per-object test: for a @FromResource copy it does remote().loadwait(name, ver) (blocking) and for an ordinary class it throws. The non-blocking read is cl.getClassLoader() instanceof ResClassLoader → getres().name, else ResClassLoader.getsource(cl).name() (the annotation, no load) — climbing superclasses, since resource widgets are routinely anonymous subclasses. A server widget gets its code this way whenever the type name contains / (Widget.gettype3)  
What a resource’s image layer caches, and where its identity is lost Resource.Image holds four pictures for the resource’s life: img (the raw decode), scaled (UI-scaled, built in the constructor — scaled() is a bare return), and the two lazy TexIs tex() (over scaled) / rawtex() (over img); Resource.loadsimg(name) = loadrimg(name).scaled(). None of the four carries a back-reference to the resource — the only trace is the anonymous TexI’s toString, and scaled has not even that. Tex and BufferedImage define no equals/hashCode, so a picture object is identity-comparable and anonymous: which resource is this picture? has no answer at the widget that holds it, only where it was minted  
Jar-relative path resolution Utils.srcpath  
Local data dir (%APPDATA%) Config.localdir — haven-config.properties, and the data/ folder of HashDirCache: the resource cache, the recorded map and the minimap icon settings when the store is the files one; with -Dhaven.store=sqlite those are savedata/map.sqlite and savedata/rescache.sqlite beside the jar instead (rescache.md). The client’s own file is not there: savedata/client.sqlite, beside the jar, holds every preference and what the client keeps about an addon (prefs-and-options.md)  
Settings, and where each is written prefs-and-options.md — the client’s file, its 6144-byte list budget, and what each OptWnd panel writes  
Warnings and the error log Warning.issue() — every warning to stderr; one at Warning.CRITICAL also appended to haven-errors.log beside the client, in the folder that holds savedata/, through the fork’s path helper rather than Debug.somedir. Debug.somedir resolves against user.home and is what the click and light dumps behind the debug flags still use. issue() reads no preference, so a warning out of the preference store cannot recurse into it  
Audio audio.md — the three channels, playing and stopping a clip, per-clip volume, ambience  
Buffs GameUI.buffs is a Bufflist (a plain Widget, 5 per row) — the buffs are its children(Buff.class) in child order = draw order (GameUI.addchild "buff" addchilds them, so it is arrival order, not sorted). Buff.res (an Indir → Loading-throwing), Buff.info() (ItemInfo list, empty until the server’s tt). The same resource can be up twice ⇒ identity is the widget, never the res name. Removal is not immediate: Buff.reqdestroy only sets protected dest and starts a 0.35 s fade NormAnim, calling destroy() at its end — so a removed buff stays a live child for ~21 frames. Read the flag via AddonWidgets.buffDest and treat dest as gone, or removals lag by that fade. A destroyed buff stays READABLE: Widget.destroy only unlinks the widget from its parent — it clears neither res nor the cached info, so a Buff a handler stashed keeps answering after removal (:exists() is a bar-membership scan, not a liveness flag on the widget)  
The Kin window kin-window.md — the buddy roster, its palette, and the village and realm panels beside it  
The character sheet character-sheet.md — CharWnd and its six tabs: attributes and FEP, study, skills and credos, the deck builder, quests, wounds  
HUD meters (the “vitals” bars) The engine’s own ordered record is the private List<Widget> meters field of GameUI (relaxed to package-private, // addon:) — appended at the place == "meter" seam, dropped in cdestroy; prefer it over a children(IMeter.class) DFS, whose order is a tree artefact. Identity = IMeter.bg, a server-published Indir<Resource> (Loading until cached; observed gfx/hud/meter/{hp,stam,nrj,häst,mount}, the last two only while mounted). Bar contents = the protected List<Meter> of LayerMeter — genuinely multi-segment, each Meter a fraction a (0..1) + a Color, replaced wholesale by the "set"/"col" uimsgs. No absolute numbers, no hunger  
HUD speed selector (crawl/walk/run/sprint) Speedget (@RName("speedget")), placed by the server under the HUD with no GameUI field → children(Speedget.class). Public int cur (the selected speed) and int max (the highest currently selectable — draw gives i > max the disabled icon). Select = Speedget.set(n) → wdgmsg("set", n), which is exactly what mousedown (hit-tested by icon width), mousewheel and the speed-up / speed-down / speed-set/0..3 KeyBindings all send. Names = the static tips[], built in the class initialiser from Resource.local().loadwait("gfx/hud/meter/rmeter/<name>-on").flayer(Resource.tooltip)  
Action bar / belt State: GameUI.belt (BeltSlot[144], dense) + beltwdg (Belt); activate = Belt.act (wdgmsg("belt", n, …)). Mutate: assign = Belt.dropthing wdgmsg("setbelt", n, "res", pag.res().name) (or "pag", pag.id — session-local id), clear = right-click Belt.mousedown wdgmsg("setbelt", n). The server ECHOES back: uimsg "setbelt" / "setbelt2" fill belt[n] via loader.defer → the write is async and a bad res name is silently dropped. A slot is one of two BeltSlot kinds: ResBeltSlot (an inner class, rdt + a GSprite) or PagBeltSlot — public static, new PagBeltSlot(idx, pag), whose whole body is pag.button().spr() to draw and pag.scm.use(pag.button(), iact, false) to fire  
Action menu (paginae) GameUI.menu → MenuGrid: paginae (HashSet<Pagina>, mutated on the UI thread under its own monitor — leaf entries only), intern table pmap (private CacheMap, WEAK) + paginafor; Pagina id/res/anew (>0 = new discovery), res(), parent(), button(); PagButton name(), act() (Resource.AButton: .ad = the action path, .parent), tooltip = res.layer(Resource.Pagina.class).text, bind (KeyBinding.key() → KeyMatch .chr/.keyname/.modmatch), sortkey(), use(Interaction); layout closure cons → updlayout  
Minimap icon registry GameUI.iconconf is a GobIcon.Settings — the registry the “Icon settings” window edits. public Map<Setting.ID, Setting> settings; the key is a PAIR, Setting.ID = (res the icon resource name, sub an opaque Object[] decoded from the resource’s own Icon.Factory.enumerate — Icon.nilid for the built-in one-icon-per-resource case). Per setting: public show / notify / defshow booleans and Setting.icon.name() (the tooltip; Loading-throwing). Write exactly as the checkboxes do — flip the boolean, then dsave()  
Crafting Makewindow (@RName("make")), wrapped in private GameUI.makewnd at place="craft" → locate via children(Makewindow.class). Public: rcpnm, inputs(Input)/outputs(SpecWidget) → Spec (item/constraint ResData.res, num, opt()), qmod/tools (List<Indir<Resource>>). inputs/outputs/qmod swapped wholesale off-thread (inpop/opop/qmod uimsg); tools in-place add (tool uimsg) → copy under synchronized(ui). Make: wdgmsg("make",0\|1) A window is per RECIPE: Makewindow(String rcpnm) takes the name on the ctor and GameUI places a fresh one, so opening another recipe REPLACES this window rather than changing it. inpop/opop destroy() every slot widget and rebuild — including the partial form (INT.is(args,0), one slot) — so nothing may key on a Spec/Input identity
Equipment GameUI.equwnd → private Window wrapping the single Equipory (@RName("epry")), reached via children(Equipory.class) (typically the only one open; a second appears while inspecting another gob’s gear). A worn item is a direct GItem child of the Equipory — addchild wraps it in one WItem per slot index it fills (wmap), cdestroy tears those down. GItem carries res (an Indir<Resource>, set at construction) and rawinfo/info() — derived, rebuilt from rawinfo on demand and throws a bare Loading (not resolvable — addcontinfo/sprite() internals) when the resource itself is still streaming; the "num"/"chres"/"tt"/"meter" uimsgs write the num field / resource / tooltip / the meter field respectively — and neither field is the whole of the number it looks like, since WItem.draw paints the icon’s count from info() alone and its arc from meter or info() (state.md)  

Gotchas that cost time.