Brodgar client

State roots: the client’s read surfaces

Where game state actually lives: gobs, map, player, items, character, party, time.

What Where
Global root Glob via ui.sess.glob (UI.sess, Session.glob)
Object cache (gobs) OCache: iterate, getgob, callback/uncallback (/). ⚠️ The iterator is wider than getgob: it walks objs.values() plus the registered local collections, while getgob looks only in objs — which is a MultiMap<Long,Gob>, so one key may hold several gobs. And OCache.Virtual mints negative ids (nextvirt, from -1 down, per OCache, so one negative id names a different gob in each session and is no server object’s): Skeleton oc.adds a FixedPlace purely to carry a one-shot effect overlay, and Gob.ctick’s virtual && ols.isEmpty() && no Drawable ⇒ oc.remove drops it the tick that overlay ends. Gob.virtual is not that class’s mark alone: OCache.receive also sets it on a server object whose delta carries flag 2, under its real id, so instanceof OCache.Virtual is the test for a client-minted id. So an id the iterator just yielded can answer null from getgob immediately — never read a nil there as “the property is unset”
What a ChangeCallback is holding when it fires the gob’s own monitor, in every one of the four paths: OCache.add takes synchronized(ob) around its whole callback loop (remove, ladd and lrem do the same), the cbs snapshot always being taken under synchronized(OCache). So a callback may not take any lock the tick holds under that gob, and — the useful half — anything that itself opens with synchronized(ob) is ordered strictly after every callback has run, which is what MapView.Gobs.addgob does on the Loader thread it was deferred to. cbs is a WeakList, so a caller of callback(cb) must hold its own strong reference or the registration simply stops firing
What a dropped render add costs, and what takes it back MapView.Gobs.addgob runs on a Loader thread through Loader.defer(Runnable, T) — the capex = false overload — so whatever it throws is rethrown out of Loader.Future.run and kills that thread, with the gob still parked in Gobs.adding, never in current, and so out of the render tree while the OCache goes on holding it, unhidden: an object simply absent from the scene until the whole set is rebuilt. ⚠️ Loading is a RuntimeException, and it is the one that must go on escaping — the Future waits on it and re-runs the task, which is the only retry there is — so it is caught and rethrown above the arm that bounds the rest, which issues a Warning naming the gob’s id and its Drawable resource. What takes the object back is Gobs.tick(), a 0.25 s reconcile called from MapView.tick inside synchronized(glob.map), off the dormant path and skipped while slot is null. It does both halves: add what the oc holds and the view has in neither current nor adding — both, or a gob whose deferred addgob has not run yet takes a second slot and paints twice a frame — and evict what current holds and the oc no longer does, under the gob’s own monitor like every other caller of removed(Gob). Gobs.want(Gob) is the single predicate for does THIS view hold it, which SessionGobs extends with skipgob and vis so the merged view’s culling and the base’s rule cannot disagree
Game object Gob: id, rc, a, getattr, getc
Gob attributes GAttrib: Drawable.getres(), Moving, GobHealth.hp, GobIcon, Speaking
Gob attrib map Gob.attr is package-private (reflection from outside haven); keyed by attrclass = the subclass directly under GAttrib, so getattr(C) only matches that exact key — a same-named class from another loader misses (gob-sprites.md, the adopted-copy gotcha). It is a ConcurrentHashMap, so a null value is refused: the delete path is setattr(ac, null) reaching attr.remove(ac) alone, and iteration ORDER is unspecified — Gob.eqpoint answers the first EquipTarget it walks past, so a gob carrying two may answer the other one
Who writes the attrib map, and who walks it Every write is under the gob’s own monitor, and the map is the funnel’s alone: Gob.setattr(Class, GAttrib) and Gob.delattr are synchronized, and setattr(GAttrib) passes through the first. The monitor is there for atomicity, not for the map — setattr is a read-modify-write across two structures (attr.remove, RUtils.multirem of the displaced attrib’s slots, RUtils.multiadd of the new one, attr.put, prev.dispose()), and two writers interleaving in it leave a gob whose attribs and whose render slots disagree. The map is concurrent for the readers, which hold nothing: Gob.added(RenderTree.Slot) and Gob.ctick walk attr.values() off the Loader thread, ModSprite.omods off whichever thread rebuilt the sprite’s mods, and Gob.eqpoint/Gob.dispose off either. OCache.GobInfo.apply already holds the monitor when it calls setattr — monitors are reentrant, so the delta path takes it once
Which render slots an attrib is standing in GAttrib.slots is package-private to haven, filled by GAttrib.added/removed as the tree hands the attrib its own child slots. So taking one attrib out of a gob already in the scene, or putting it back, cannot be written outside haven at all — the helpers are RUtils.multiadd(gob.slots, node) / RUtils.multirem(attr.slots), exactly what Gob.setattr uses to swap a Drawable, and multiadd rolls its own partial adds back before rethrowing. Copy the collection before multirem: it is the very list removed mutates
Retrying slot surgery that throws Gob.defer(Runnable) queues onto Gob.deferred, run from a Loader.Future — so a task that throws Loading (adding a Drawable whose sprite is still resolving) is parked and re-run with no retry loop of your own (boot-and-loop.md). The queue is FIFO and the task is only dequeued once it returns, so a parked one holds that gob’s later deferrals behind it; the body runs under synchronized(gob) and is skipped entirely once the gob is removed
Kin mark on a gob res/ui/obj/buddy/Buddy (@FromResource v4): public id = the BuddyWnd.Buddy id (kin-window.md). Server-stamped on every gob of that kin — body and hearth fire (gfx/terobjs/pow)
Map / terrain MCache: gettile, tilesetr, getcz, getgrid; tilesz/cmaps (/). The height at an arbitrary world point is getcz(Coord2d) — bilinear across the four surrounding tile corners, each MCache.getfz(Coord), the DRAWN read (the plane under flat terrain); getrealfz/getrealcz are the STREAMED reads beside them, and the whole chain is terrain-height.md — and getzp(Coord2d) is that same read wrapped as the Coord3f the projection wants (MapView.clipxf/screenxf take one). Both go through getgridt, so ⚠️ either throws MCache.LoadingMap for ground this client has not been sent — a point outside the streamed area has no height at all, and a per-frame caller has to catch it rather than assume zero
Player id / gob / camera MapView.plgob, player(), getcc(), camera
Inventory / items GameUI.maininv, Inventory.wmap, GItem (res, num, meter, info() — see the icon-numbers row below for what num and meter actually are). Inventory.addchild builds one WItem per item at args[0].mul(sqsz).add(1,1); cdestroy does ui.destroy(wmap.remove(i)) unguarded. The reverse of wmap is on the icon: WItem.item is the GItem it draws, public final and set in the constructor, so it answers from the moment the widget is built
Equipment GameUI.equwnd → Equipory.wmap = item → a collection of WItems: addchild makes one per ep index in args, so a two-slot item is drawn twice and children(WItem.class) returns it twice. Slot from a coord: epat; slot name: ettstr[ep] (etts[ep] is that string rasterised at class init, and betts[ep]/etttip the re-render), filled only where gfx/hud/equip/ep<i> has an image layer — 23 slots, 22 names (slot 16 has none, and the server does place items there)
The item on the cursor GameUI.addchild place == "hand" does add((GItem)child) — a real server-bound GItem under the HUD, so it has a widget id like any other; GameUI.vhand is only the WItem that draws it, rebuilt by updhand from the hand list
What an item holds GItem.contents (the widget the server pushed), contentsnm (its caption), contentsid, contentswnd. All four are written by GItem.addchild — which does not add the child under the item: it puts it in a new GItem.ContentsWindow added to contparent() = getparent(GameUI.class). The items inside are that widget’s GItem children, which is the walk GItem.addcontinfo makes over contents.children(); updcontinfo invalidates the holder’s own info when a child’s infoseq moves
…and what holds it GItem.ContentsWindow.cont, public final: the item that pushed the contents. Climbing Widget.parent from a contained GItem to the first ContentsWindow is the only back-link there is, and climbing on from there chains through a container inside a container
A tooltip that states its contents ItemInfo.Contents extends ItemInfo.Tip, public final List<ItemInfo> sub — the block a bucket carries instead of items. Built by resource code (ui/tt/cont on the wire), so it is matched by type; GItem.info() throws a bare Loading while the resource streams. sub is a whole nested tooltip — that factory fills it with ItemInfo.buildinfo — so what is inside describes itself with the same rows an item uses: an ItemInfo.Name (or AdHoc) for the stated line, whose text is Name.source()/AdHoc.source() (Name.str.text is that line rasterised), and its own QBuff for the content’s quality
A fill meter published resource code again: ui/tt/level’s Level (public final double cur, max), adopted at res/ui/tt/level/Level.java (@FromResource v21) so the two counts are reachable by type. It is an ItemInfo implementing GItem.OverlayInfo<Double>, not a Tip: the engine asks it for overlay() = cur / max alone and WItem.draw paints that over the icon through itemols, which walks the item’s own info(). So nothing in haven reads the counts, and nothing renders this as a tooltip row
The two numbers on an item’s icon WItem.draw is the authority for both, and it reads two sources each. The count: GItem.num (written by the "num" uimsg, -1 when unset) is drawn nowhere — the number comes only from itemols, the AttrCache of GItem.InfoOverlays built from the GItem.OverlayInfos in info(), and GItem.NumberInfo extends OverlayInfo<Tex> is the one that renders a count (itemnum()). What that number counts is the implementor’s, and there is no finer type: GItem.Amount.itemnum() is an amount, while res/ui/tt/slots_alt/ISlots.itemnum() is s.size(), the gildings applied — drawn, 0 included, unless ignol (set when the owner’s fcontext is a MenuGrid). The arc: (item.meter > 0) ? item.meter / 100.0 : itemmeter.get(), that second half being the first GItem.MeterInfo of info() (meter()) — so the field is a percentage and the interface a 0..1 fraction, and g.prect paints meter * 2π. GItem.uimsg "meter" writes the field
An item’s durability published resource code once more: ui/tt/wear’s Wear extends ItemInfo.Tip (public final int d, m), reachable in GItem.info() by class name. It is a plain Tip and nothing else — it implements neither GItem.MeterInfo nor OverlayInfo, so a worn item paints no arc from this row and the two are genuinely separate data. tipimg renders Wear: d/m and re-renders it in red once d >= m, which is the whole of what the client knows about what they measure
Item quality published resource code, not haven: ui/tt/q/quality’s Quality extends ui/tt/q/qbuff’s QBuff (public double q, public String name), reachable in GItem.info() by class name. An item may carry several QBuffs (gilding is one)
Item metadata / name ItemInfo: Name (Name.source(), null where the caller supplied a rendered Text), find, buildinfo
A thing an icon draws that is not a GItem ItemInfo.SpriteOwner extends ResOwner extends Owner — info(), resource(), sprite() — is the engine’s own name for “something drawn as an item”, and haven has three implementors: GItem; Makewindow.Spec (a recipe’s ingredient or product: item/constraint ResData, num, info() = ItemInfo.buildinfo(this, rawinfo)); and ItemSpec (res ResData, ctx, info), which is shipped for resource code to hold one of its own. So a class a .res loads is free to implement the interface itself and be a fourth kind this tree never sees. ⚠️ None of the three but GItem is a widget, so a depiction has no id, no parent and no death of its own: it lives and dies as a field of whoever draws it
…and the widgets that draw one WItem.item (public final) for a GItem; Makewindow.SpecWidget.spec (public final, and Makewindow.Input extends SpecWidget is the input half); BAttrWnd.Constipations.ItemIcon.spec (public final, an ItemSpec the row mints in Constipations.Item.update); and, for a .res owner, the widget is the SpriteOwner. ⚠️ Makewindow.uimsg inpop/opop destroy() every slot widget and rebuild, so a SpecWidget and its Spec both end at a recipe change. ⚠️ A recipe slot describes itself before it is on screen: SpecWidget’s constructor calls spec.opt(), which builds the list, and it does that before Widget.add0 has put the widget anywhere — so anything hung on the widget’s ENTRY sees a Spec that can already be described, with no build left to watch. Makewindow.Spec.info() and ItemSpec.info() each cache for good and nothing nulls either, so a revision is a NEW owner object rather than a second build on the old one. ⚠️ A constipation row does not: BAttrWnd.Constipations.ItemIcon extends SListWidget.IconText, whose drawicon/drawtext reach img() and text() — and so spec.image()/spec.name() — at the widget’s FIRST DRAW, catching Loading and retrying on the next one. Constipations.Item.update() has added it to the tree by then, so its build is one an entry watcher can still see, and a .res owner that revises is not the only one that has a build left. ⚠️ The owner cannot name its own widget back: Makewindow.Spec.context(Widget.class) resolves through Makewindow’s ClassResolver, which matches by isAssignableFrom and answers the window. Session.class is the only key all four kinds share, and ItemSpec.context delegates to whatever ctx it was built with (a uictx.curry(ui), a GItem, a .res widget’s own resolver), so resolve narrowly and catch RuntimeException
A depiction built inside a tooltip res/ui/tt/slots_alt’s ISlots builds two ItemSpecs per gilding slot straight into a tooltip image. No widget draws them and nothing can address them — the only SpriteOwners in the client that are not on screen as themselves
When an item becomes describable ← two addon seams Every owner builds its list lazily and caches it, so a build runs once per arrival and once per revision, never per frame. Two things gate it and only the first is a message: for a GItem, uimsg "tt" nulls the cache and stores rawinfo; ItemInfo.buildinfo then throws Loading until the resource that renders those rows has streamed — so every read through info() answers nothing and then answers, with no message marking the moment. A GItem is tapped at the end of GItem.info()’s build block (onItemInfo(this), // addon:), because that list is complete only after the method has appended addcontinfo’s rows and the Resource.pagina one. Every other SpriteOwner is tapped at the end of ItemInfo.buildinfo, the one door every description in the client goes through — which skips a GItem for exactly that reason. Either is reached on whichever thread first asks, which in practice is the draw of the icon
…and the two things that would fire it twice Nesting: the ui/tt/cont factory fills a Contents.sub with a whole second list built through ItemInfo.buildinfo and the same owner, so the tap is behind an identity ThreadLocal<Set<Owner>> entered around buildinfo0 — only the outermost call for an owner describes it. A nested build for a different owner (ISlots minting an ItemSpec per gilding slot) is outermost for that one and taps for it. A font change: Fonts.gen() moving nulls a GItem’s cache so the tooltip re-renders in the new face, and GItem.fontrebuild (// addon:) suppresses the tap for that rebuild — the letters moved and the item did not. ⚠️ No other owner watches Fonts.gen() at all: Makewindow.Spec.info and ItemSpec.cinfo are built once and never rebuilt, so a depiction’s list is as old as its widget
Character attributes Glob.getcattr, CAttr{base,comp}. The sheet that draws them, and the public CharWnd.exp/enc beside them, is character-sheet.md
Party Glob.party: memb, leader, Member.gobid/seq, getgob(), getc() ( — live gob position if in view, else the last-known one the server sent, null before either), col. Written only by Partyview.uimsg: "list" rebuilds memb into a NEW map but reuses the existing Member for an id that stayed, so the object is stable across a roster push and only a joiner is minted; "m" writes one member’s coord/colour; dispose empties it. A member has no name — the client is never sent one
Time / astronomy Glob.globtime() (the client’s interpolated clock) and Glob.ast → Astronomy dt/night/mp/yt/is, plus the light fields — how the clock converges on the server’s, and what is means, is game-clock.md
Gob speech / icon Speaking.source() (reliable; Speaking.text is the rendered bubble); GobIcon.Icon.name() (Loading-guarded)
Gob overlays Gob.ols (public Collection<Overlay>); Overlay (id = the SERVER’s, -1 when it gave none; spr.res.name is the only nameable identity), addol ( body / one-arg / / /), addolsync, findol(id), removal at. Three removal paths and only two go through Overlay.remove — the third is ctick’s own expiry, which calls remove0() + i.remove() directly

Gotchas