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 |
rc is login-relative; the shareable anchor is grid id +
within-grid offset. Grid/segment ids are 64-bit → expose as decimal strings.ContentsWindow is not destroying it: reqclose() is chstate("hide") and nothing more —
only cdestroy(inv) nulls contents/contentsnm/contentsid/contentswnd and destroys the window. So
everything a contents widget holds is readable with the window down. wndshow has exactly one caller,
GItem.uimsg "contopen", so no client can pin that window open — the server decides.GameUI, not under the container it appears to be in: the window hangs
off contparent(), so hasparent(inventory) is false for it and a children(WItem.class) walk taken from
a node above the HUD sweeps contained items in.Loading — swallow to a partial/absent value or defer.GAttrib dies with its gob for free: Gob.dispose() disposes every
attrib, and a gob dropped from OCache.objs takes them with it — so state attached to a gob needs no
prune, unlike an addon-side map keyed by gob id. setattr adds a RenderTree.Node
attrib to the gob’s slots and can throw Loading; it also dispose()s whatever it displaced.catch(RuntimeException) around slot surgery swallows the retry. Loading extends
RuntimeException, and every RenderTree add that reaches a Drawable may throw one — so an arm meant
to bound a failure catches the parking instead, and an object whose model was merely still streaming
is dropped as if it had failed. Catch Loading first and rethrow it. RenderTree.SlotRemoved is an
IllegalStateException, so it needs its own arm above too, or the specific handling it already has is
shadowed.Gob.added(RenderTree.Slot) must stay unsynchronized.
MapView.Gobs.addgob opens with synchronized(ob) and only then calls slot.add; OCache.add,
remove, ladd and lrem hold it across their callback loops; Gob.deferred runs each queued task
under synchronized(this). RenderTree.TreeSlot.add calls Gob.added while holding the tree, so a
monitor there would be tree-then-gob, the inversion this client deadlocks in — it needs none, because
every caller of it already holds the gob. Anything new that touches GAttrib.slots goes the same way
round: take the gob, then let RUtils reach the tree from inside.dispose(): OCache.remove only calls
ob.removed() (sets a flag) — Gob.dispose() is NOT on that path. So
anything a gob merely points at elsewhere (its own client gob in the scene, a GL resource) must be
ended on the gob-removed seam: the screen-vs-world gob:overlay asymmetry, and follow=’s orphan.
Likewise a registry asking whether an attrib is still in play reads Gob.removed as well as its own
disposal: GAttrib.dispose never runs for an object the server took away.ctick self-removes a VIRTUAL gob when ols.isEmpty() and it has no Drawable
— a client-only gob must keep something in one of the two, or it
vanishes on the next tick. ctick also drops a finished overlay (ol.tick(dt) true,) without calling
Overlay.remove — which is how most of the game’s overlays actually end (they are transient sprites
nobody removes by hand), so anything watching removals must hook that line too.addol(ol) is addol(ol, true) = ASYNC — it defers through Gob.defer onto a loader thread, so
the one-arg and two-arg forms are the same event: hook the two-arg body or every add fires twice.
Overlays also arrive from server messages off the UI thread; nothing may call into Lua from there.MCache.getgrid REQUESTS what it cannot find : a miss calls
request(gc) (re-sent up to five times) and throws LoadingMap. Right for “draw this ground”,
wrong for “where is this place” — a locating read uses a plain synchronized(grids) lookup instead, which
is what AddonWidgets.loadedGrid is. Grid.ul is gc * cmaps in session tiles, the same point
the map file derives as (sc * cmaps) - sessloc.tc, which is why one anchor survives a grid unloading.MCache.grids has exactly one door in and three out, and a miss is none of them. In:
mapdata2, and only for a coord already in req — plus AddonWidgets.putgrid (// addon:) on a cache
filled from somewhere other than the wire, which fills a grid already there again in place
(Grid.refill, // addon:) as mapdata2 fills one the server sends again. Out: trimall, trim(ul, lr) and drop(Collection)
(// addon:), and each of the three calls Grid.dispose() in the same statement that unlinks the entry,
so Grid.removed is set on grids that are already gone and never on one still in the map — which is
why numgrids() (the raw size) and a walk that filters removed agree. ⚠️ getgrid’s miss looks like a
fourth door and is not: it fills req and throws, leaving grids untouched. So a source that fills its
own cache through the one door knows its whole membership from its own calls, and needs no copy of the
cache to find out what it is holding — which a source that keeps an LRU over grid coords would otherwise
take once per tick.invalblob
type 2 calls trimall(),
dropping every grid, and the ground that streams back in arrives under different grid coords. Entering a
cave or a house does exactly this, so the same Gob.rc numbers name different ground before and after.
Anything that must still mean somewhere afterwards has to be kept as a grid id + offset, never as a
session coordinate (see minimap.md for the sessloc side, which lags the drop).GItem.updcontinfo() returns
immediately while info is null, and a Fonts.gen() move nulls info — so a tick landing between the
two skips the contents check altogether, and the revision it would have found fires on the tick after the
rebuild instead. Nothing is lost and nothing fires twice; what moves is when. So anything measuring what
a font change did to an item has to allow for a revision that was already pending landing inside its
window: a second font change is what separates them, since only something answering to the font
answers to both.