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.
haven reserves belt[n] for the server. It is a plain array the uimsg arms assign, so a
PagBeltSlot written into it from outside draws and fires like any other slot — the array is simply
overwritten, and what was there is lost unless the writer kept it. Six arms write it across the two
messages (setbelt: clear, res; setbelt2: clear, "p", "r", "d"), and two of them defer the write
onto a glob.loader.defer task — setbelt-with-res and setbelt2 "r" — so those land after the message
has been dispatched, on a Loader thread. uimsg itself runs on the message thread, not the UI thread.belt[slot] = … is a resource load, so at login the whole burst is dispatched around the time
GameUI is built while the resource-backed slots fill in over the following moments. Anything written into
belt[n] from outside in that gap is silently replaced by a task that was queued before it existed — the
lambda captures only slot and rdt and re-reads nothing. A writer that means to keep the slot has to
re-assert it after the write lands, and the deferred lambda is the only place that instant is observable.Speedget.cur/max are written by nothing but their own uimsgs ("cur", "max"), so set(n) is a
round trip: the field still holds the old speed on the next line, and a speed the server refuses simply
never comes back. max can also be negative — both mousewheel and globtype guard on max >= 0 — and
that is a live selector on which nothing is selectable, not an absent one.tips[i] is the tooltip of the -on variant of the artwork,
so it reads "Run On", and the widget’s own hover text is "Selected speed: Run On". The initialiser
loadwaits all four, so touching the class blocks until they load — and the names therefore exist before any
selector does.GameUI.menu is not built by GameUI. It is assigned in GameUI.addchild when the server places a
child with place == "menu", so it is null for some ticks after GameUI itself is in the widget tree —
the same is true of every other place-named panel. Anything that needs the grid has to wait for the
field, not for the HUD: the two are different instants, and nothing in haven announces the second.PagBeltSlot.use goes through MenuGrid.use(btn, iact, false), not PagButton.use — the widget’s
click handler. For an entry with children that flips the grid’s visible page instead of acting, which is how
a category on the bar behaves; for a leaf it clears anew/tnew and then calls PagButton.use.MenuGrid.paginae is NOT the whole menu. It holds only the entries the server granted; the categories
they hang under exist solely in the private pmap, reached through Pagina.parent(). Anything enumerating
the menu must walk the parent closure (what cons does) or it gets no categories and no roots.Loading — res(), button(), parent(), act(). Right after login the
set is therefore short and fills in sub-second. Never resolve while holding the paginae monitor: res.get()
can block on the loader. Copy under the monitor, resolve outside.Pagina can be SUBCLASSED into the grid, and MenuGrid needs no edit for it. The engine reaches
everything about an entry through two virtuals — Pagina.button() and the PagButton it returns — so a
subclass pair is the whole seam: cons walks paginae plus the parent() closure, updlayout sorts on
PagButton.sortkey(), draw reclips to spr().sz() (device pixels; the cell interior is
Inventory.sqsz.sub(1,1)), and MenuGrid.use decides “category” by cons(pag, sub).size() > 0. Two
traps: Pagina.button is private, so the next/bk trick of pre-assigning it from an initialiser is
unavailable — override button() and cache in the subclass; and Resource’s constructor is private and
pool-managed, so a synthetic entry has to pass an existing resource as a stand-in and nothing may key on
its res()/PagButton.res.PagButton(Pagina) calls the virtual binding() from its own constructor — after pag and res,
before any subclass field exists, so an override must read pag and nothing else (no warning). The stock
binding() reaches hotkey() → act() → res.flayer(Resource.action), which throws for a resource
with no action layer (gfx/hud/sc-next has neither an action nor a pagina layer), so a stand-in
entry must override it — KeyBinding.get(id, KeyMatch.nil) also gives it an unbound, remappable idPagButton.parent() MEMOISES the paginafor(act().parent) it derived, into a private field, and
Pagina.parent() is just button().parent() — so a parent that is not fixed by the resource has to be
answered by an override on both, from state read live, or the first call freezes the tree.cons reaches a category through parent() alone and never asks whether that parent is still in
paginae. A parent taken out of the set is therefore still emitted onto whatever screen its parent names:
removing a category without re-rooting its children draws the removed category itself back on the root
screen. Its BFS does tolerate a cycle (the close/open sets absorb one), but the anew/tnew walk at
the head of the same method is an unguarded for(p; p != null; p = p.parent()) — one new discovery whose
chain leads into a cycle hangs the client.updlayout() and the recons flag are private; MenuGrid.change(cur)
is what rebuilds curbtns and layout after paginae is mutated outside a "fill" uimsg. It resets
curoff, so a change made while the player is on page 2 of a category puts them back on page 1PagButton.rendertt(withpg) renders name() (plus the
bound key) and, for the long form, appends ItemInfo.longtip(info()) — where info() is
ItemInfo.buildinfo(this, pag.rawinfo) plus an ItemInfo.Pagina built from res.layer(Resource.pagina).text.
So an entry over a stand-in resource, which has no pagina layer, has to override info() to say anything
at all below its name. rendertt deletes from the list info() hands it (removeIf on ItemInfo.Name),
so an override must return a fresh list rather than a cached one. MenuGrid.tooltip then caches the rendered
Tex per hovered button (curttp), and text that changes while the pointer is already on the button
re-renders on the next hover.PagButton.use(Interaction) ignores Interaction.modflags — it reads ui.modflags() live and branches
"act"-by-path vs "use"-by-id internally (the only route to an id-only pagina). MenuGrid.use(btn,…) is the
widget’s click handler instead: for a category it flips the visible page and resets grid state.BindingPanel) — a registered
binding with no addbtn line is invisible, and keys handled by raw ev.code/ev.c in a globtype
override (RootWidget’s ` and :) are not in the registry at all. The action bar’s two rows
were such a case and are not any more — see widget-input.md.OptWnd.BindingPanel.SetButton, an inner class of the panel, so it
is built only inside an Options window; a key button standing in any other tree is a KeyMatch.Capture
subclass of its own. Its cmd is final; draw shows cmd.key() every frame, by identity, without writing
it, and Backspace (tkev().sym() == Key.Std.BACKSPACE) puts the binding back on its default.
⚠️ SetButton.set(KeyMatch) is the write (Capture.set, then cmd.set):
called to display a key, it makes a binding on its default an assignment. KeyMatch.Capture.click toggles the key
grab — open, the caption reads ...; a second click closes it and restores the caption — and keydown closes it
only when handle answers true, which a bare modifier never does.
Capture.capturing() answers whether the grab is open, and cancel() closes it and restores the caption: Escape
without a key (fork addition). ⚠️ The fork’s disabled cut runs inside a key grab’s dispatch too — UI.dispatch
walks grabs first, but UI.WidgetGrab.handle is Event.dispatch, which is Widget.handle, whose first line is
the cut — so a capture button the cut disables lets the key pass on to the tree with its caption still ...
until something calls cancel().GobIcon.Settings.settings is SWAPPED WHOLESALE by the loader thread (Loader.run builds nset and assigns it), minting fresh Setting objects as icon resources resolve. So a local reference to the map is a stable snapshot to iterate — but anything keyed on a Setting’s Java identity goes stale the first time a new icon arrives, writing to an orphan. Key on Setting.ID.res.dsave() is debounced through Defer : it returns immediately and save() runs on another thread, coalescing a second request into saveagain. save() itself no-ops when ResCache.global == null, which is what makes a headless subclass-the-sink test possible.Settings.receive, whose two arms (one resource, or an Object[] of them) both build a Setting with nilid; the sub variants only appear from a stored config’s "sub" list.