Brodgar client

The on-disk store: ResCache

Everything the client keeps on disk outside savedata/ goes through one two-method interface and one of two implementations: a hash table of files under %APPDATA%, or — with -Dhaven.store=sqlite — two SQLite files beside the jar. What the store holds for the map is mapfile.md; the pipeline that reads and fills the resource half is resource-loading.md.

The interface and its implementations

Class What
ResCache store(name) → OutputStream, fetch(name) → InputStream. A miss is FileNotFoundException, and every caller keys on exactly that (Pool.handle, GobIcon.Settings.load, MapFile’s sfetch sites); any other IOException is an error, not an absence. ResCache.global is a static of the interface, built at its initialisation by ResCache.StupidJavaCodeContainer.makeglobal — the store is chosen the first time any class touches ResCache.global, and a Warning issued then reaches stderr only
HashDirCache Upstream’s one live implementation: one file per entry under Config.localdir()/data, mechanics below. HashDirCache.create() picks the identity — haven.cachebase; else -U, with the brodgar.io cache host mapped to the proxy’s URI (Resource.BRODGAR_CACHE_FALLBACK); else urn:haven-cache:default — and answers null on any failure, which every caller tolerates
The SQLite store The fork’s second implementation, src/io/brodgar/addon/SqliteCache.java: one blob per entry in one SQLite file, two files when selected. Its mechanics are that class’s; how it is wired into haven is the seam below
ResCache.Fallback, ResCache.TestCache A primary with read-only secondaries (store goes to the primary); a store that prints what it would have written and misses every fetch — a sink, not the in-memory store a headless MapFile wants
BaseFileCache, FileCache, SteamCache Dormant: nothing in the client constructs them. A per-identity folder tree under Config.localdir()/cache with an index file; a tree under ~/.haven/hafen/cache; the Steam Cloud files (steam.md)

Who writes which key

Key family Writer Reader
res/<name> Resource.addsrc’s Caching, a TeeSource every network source is wrapped in: its fork is cache.store("res/" + name) over Resource.prscache, the store Client.setupres handed to Resource.setcache. The tee’s setncwe — no close without EOF — closes the fork only when the download was read to its end Resource.CacheSource.get, cache.fetch("res/" + name), walked after the jars and before the network
map/[<file>/]index, …gi-<id>, …grid-<id>, …seg-<id>, …zgrid-<seg>-<lvl>-<x>-<y> MapFile.sstore through mangle: map/, then <filename>/ when GameUI.mapfilename() is not "" — by default it is, so the keys are map/index, map/gi-…. Ids print as %x. The index is written whole on every save; a grid on Grid.save; a gi- and a seg- on BackCache.put (gridinfo, segments — the processor thread’s saves, never an eviction); a zoom grid on ZoomGrid.save MapFile.sfetch from read (the index, once), gridinfo, segments, Grid.load, ZoomGrid.load
data/mm-icons-2[/<genus>][/<account>] GobIcon.Settings.save over ResCache.global, under the name GameUI.iconconfname builds GobIcon.Settings.load; a miss is a fresh Settings
tmp/allused Nobody: the Resource.loadlist of it in Client.setupres is commented out —

The map’s store is GameUI.addchild’s mapstore (the "mapview" placement): ResCache.global, or HashDirCache.get(MapFile.mapbase) when haven.mapbase is set and the store is the files one — and every shipped haven-config.properties sets it, to http://game.havenandhearth.com/java/. So under %APPDATA% the map and the resource cache are two identities in one folder, and MapFile.load keys its one instance per database on the (store, filename) pair (mapfile.md).

HashDirCache: the mechanics

Piece How
The folder Config.localdir()/data, created by the constructor. Config.localdir is %APPDATA%\Haven and Hearth on Windows and ~/.haven elsewhere, and null when neither exists writable (services.md)
The identity HashDirCache.id, a URI: one instance per URI (get, memoised in current), and idhash = namehash(0, id.toString()) — h = h * 31 + char over the string, in a long
A slot %016x.%d: namehash(idhash, name) in hex, then an index. .0 is where a name lives unless another (id, name) pair hashes to the same 64 bits, in which case lookup walks .1, .2, … reading headers until it finds the name or an empty slot. The chain has never been exercised: CacheFile takes the .0 channel whatever slot matched, so a chained entry would read back as the first one’s bytes
The header readhead/writehead: a version byte (1), then writeUTF(id) and writeUTF(name). A slot whose header does not parse counts as empty and is reused; the entry’s bytes follow the header
lookup(name, creat) Takes an exclusive LockedFile.lock on the .0 slot — which opens it READ, WRITE, CREATE and locks its first byte — and walks the chain under it. Two threads of one JVM cannot lock one file twice (OverlappingFileLockException), so a per-path monitor in monitors serialises them first; more than 100 live monitors is a Warning. The CacheFile it returns has released the lock and keeps only the channel, positioned after the header
fetch lookup(name, false): a missing .0 is a miss before any lock; otherwise the channel after the header is the entry’s stream
store lookup(name, true) first, so a new name gets its slot now, header only. Then a temp file (createTempFile(dir, "cache", ".new")) takes the header and the bytes; close() moves it over the slot with ATOMIC_MOVE, on Windows deleting the slot first (an open file cannot be replaced there, so a reader between the two sees a miss). A stream never closed is cleaned by a Finalizer.Cleaner that closes the channel and deletes the temp
remove(name) Public; deletes the slot. Only the CLI calls it
list Private: opens every %016x.%d in the folder and reads its header. Only main reaches it — java -cp bin/hafen.jar haven.HashDirCache <id or URI> ls, cat <name>, purge, rm <name>…
Eviction None. Nothing counts, ages or caps the folder; purge is the one way to shrink it, and it is per identity

Gotchas

The haven.store seam

   
The switch -Dhaven.store: files — the default, and the value when the property is absent — or sqlite. Any other value warns, naming it and the two accepted, and means files. ant -Dstore=sqlite run passes it (build.xml’s run target). Resolved once, in the store class
The three // addon: lines ResCache.StupidJavaCodeContainer.makeglobal — ResCache.global is savedata/map.sqlite, else HashDirCache.create(). Client.setupres — Resource.setcache gets savedata/rescache.sqlite, else ResCache.global. GameUI.addchild’s mapstore — haven.mapbase is consulted in files mode only, so in sqlite mode the map is ResCache.global’s file whatever the properties file says
The two files Under savedata/ beside the jar (haven.savedatadir moves it): map.sqlite for what ResCache.global holds — the map/ keys and data/mm-icons-2/… — and rescache.sqlite for res/. Two lifecycles, two files: a cache wipe never runs inside the player’s map. Each store keeps its own data — nothing is imported either way, and switching back shows the files store as it was left. Inside map.sqlite, one table per world: GameUI.mapfilename() puts the server’s genus first in every key, map/<genus>/…, and such a row lives in the table map-<genus>, made when the world is first seen; everything else — res/, data/, a map/ key with no genus — lives in entries. The tables are alike and the name stays whole, so a world’s map is one table to dump, restore or drop with any SQLite tool. The layout is the file’s user_version (2): a file at any other, older or newer, is refused with the reason and the client runs without that store for the session — nothing converts it
What the store promises haven The same contract as the files: a miss is FileNotFoundException and only a miss; a store stream writes on close() and nothing otherwise, so an aborted download leaves no row where HashDirCache leaves a header-only slot; a zero-length blob round-trips as zero bytes. A file that cannot be opened — no driver or java.sql, an unwritable folder, a file at another schema — is one Warning naming it and the reason, and null: the degradation a null from create() already has. Two clients on one folder share a file through the write-ahead log and a busy timeout
:store A static console command, registered by the store class’s initialiser in both modes and printing through cons.out — the System channel in-world (console.md). store: files with the data/ folder and the HashDirCache identities in use, or store: sqlite with a map: and a res: line — path, entry count and size, or not open: <why> — one map-<genus>: <n> entries line per world table under map:, and a sweep: line: none before a sweep finished (running while the first is on), else examined <n>, dropped <m> of the last one. :store sweep runs the sweep first, on the console’s thread, and prints the same report; in files mode it says sweep: files mode never sweeps. Any other argument is refused naming both spellings. A client command, so hafen.console():on("store") is refused
The sweep The pack (brodgar-res.jar) answers before the cache, so a res/ row it holds at the same or a newer version is dead weight; the version is the little-endian uint16 after the 16-byte "Haven Resource 1" signature. The store class’s sweep(): a daemon thread runs it once from resources() in sqlite mode, right after the open, and :store sweep runs it now; runs serialise on one lock. It reads SELECT id, name, substr(data, 17, 2) FROM entries WHERE name LIKE 'res/%' — never the blob — copies the rows out under the instance monitor, then probes the pack per row without it, the lookup JarSource.get makes (Resource.class.getResourceAsStream("/brodgar-res/<name>.res")), reading the signature and the two bytes after it. Pack version ≥ row version → the row is deleted; the pack lacks the name, is not on the classpath, or its entry is not a resource, or the row is newer, or its blob is shorter than the version → kept. The deletes are one transaction, each WHERE id = ? AND substr(data, 17, 2) = ? with the bytes the sweep read, so a row the tee rewrote meanwhile — a version the pack lacked — stays. One stderr line per run, and the counts on :store. Measured: ~80 µs per probe over bin/brodgar-res.jar, so a cache the size of the pack sweeps in under a second. Files mode never sweeps: enumerating the folder is minutes

See also