Technical white paper · ComfyStewardView
A Valheim save file stores every object as a bare integer hash. Recovering what those integers mean — and proving the recovery — turned out to be the load-bearing problem in the whole system.
Summary
ComfyStewardView parses Valheim world saves for high-player-count community servers and serves the result as a REST API and a browser dashboard. Server stewards use it for the questions that a game console cannot answer: who owns this chest, where did the coins accumulate, which portals are orphaned, whose tombstone is that, which zones are dense enough to lag.
The world it was built against holds 9,155,594 objects in a 1.27 GB file. At that scale the interesting engineering is not the dashboard — it is the parse, and specifically one detail of it.
Prefab-name coverage went from 15.6% to 99.5% of all ZDOs. Before: 7,730,680 objects unnamed. After: 49,385, across 129 hashes.
Every number in this paper was measured on a real world file, not estimated. Where a figure corrects an earlier claim, both are shown.
The problem
A Valheim ZDO — the network-replicated object record that everything in the world is made of — stores its prefab as a 32-bit hash of the prefab's name. The name itself is never written to the save. It exists only inside the running game's object registry.
So a parser reading the file cold sees 538325542 and has no way to know it is a grausten wall. Without a hash-to-name dictionary the system substitutes the literal string hash:538325542, and that string then travels everywhere: into the API, into the map, into five separate classifiers that each check for the prefix and give up.
A dictionary had solved this once before, offline. It lived on a drive that no longer exists. A full-depth search of the working tree found exactly one surviving prefab dictionary — freshly extracted from the game assembly by a sibling toolchain, 3,458 entries, generated the same week.
Finding
Wiring the dictionary in was straightforward. Reconciling it against what the codebase already believed was not.
Eight hand-written names contradicted it. Six of those were load-bearing — they were not just labels, they drove classification through hash sets, so a wrong name meant a wrong category for every object of that type. Two of the six were recorded in the project's own handoff notes as confirmed, verified by inspecting object properties.
Those confirmations were re-measured with the shipping parser, reporting the fraction of each hash's objects that actually carried the property in question:
| Hash | Recorded as | Property | Carried it | Fraction |
|---|---|---|---|---|
| 686545676 | sign_hmHildir — “SIGN, confirmed” | text | 1 of 79,881 | 0.001% |
| -1195767551 | turret — “Ballista, confirmed” | ammoType | 9 of 41,651 | 0.022% |
| 1411875912 | itemstandh | item | 0 of 173,040 | absent |
| -1161852777 | ArmorStand | item | 0 of 159,003 | absent |
| 650075310 | itemstand_rooster | item | 12 of 59,447 | 0.020% |
| -494364525 | container variant | items | 0 of 82,429 | absent |
What each hash actually carries corroborates the dictionary instead. Four of the six show health and support on 96–99.9% of their objects — the signature of a building piece. One shows scaleScalar on 98.5%: a scaled world prop, and the dictionary names it a cliff.
The original evidence came from a probe whose property capture was gated on a latch that stayed open until a string-bearing object appeared. Until then it overwrote its key sets from different objects. The printed profile was a union across unrelated records — which is how the same output attributes barley flour, honey-glazed chicken and a Goblin King trophy to a ballista.
The lesson is narrow and worth stating plainly: a property observed without a denominator identifies nothing. Twelve occurrences out of 40,889 is noise wearing the costume of proof. Every identity claim in the system now reports a fraction.
Roughly 573,000 objects were misclassified in the deployed application. Item stands were inflated by a factor of four, signs by 40%, and the world appeared to contain 41,651 ballistae. It contains 400.
| Category | Before | After | Change |
|---|---|---|---|
| Item stands | 466,724 | 107,902 | −358,822 |
| Signs | 194,774 | 115,470 | −79,304 |
| Containers | 153,387 | 74,752 | −78,635 |
| Ballistae | 41,651 | 400 | −41,251 |
| Building pieces | 3,488,502 | 3,629,427 | +140,925 |
Removals and reassignments balance exactly — every object dropped from one category was found in another, none lost or double-counted. Signs were the only category with no content-based fallback, so they gained one: the component atlas records exactly one writer for the text field and one for author, nothing else in the game writes either. That rule recovered 577 genuine signs the system had never detected.
Finding
The system retains a history of ingested saves and reports what changed between them. The comparison keyed on zdo_index — which is the parser's loop counter, the object's ordinal position in the file.
Because the save contains no persistent object identifier, that column is not identity. Comparing it compares array slots. If one snapshot holds N objects and another holds M, “present in B but absent from A” evaluates to exactly the index range N to M − 1: the difference in row counts, attributed to whichever objects happen to occupy the tail of the larger file. One of the two figures is always zero.
Identity is now prefab hash plus position quantised to one centimetre. Two objects cannot occupy the same coordinates, and a placed object keeps its position across saves. The engine also carries a self-check — added minus removed must equal the change in total rows — because about 0.8% of position keys are shared by more than one object, and a change in how many sit at one spot is invisible to a presence-based comparison.
Between two copies of the same world: 43 objects added, 43 removed, 2,366 ownership changes, no net inventory change. Wandering fauna and little else — the correct answer for two saves of one world.
Both files hold exactly 9,155,594 objects, so the previous engine reported 0 added, 0 removed: a confident description of nothing happening.
Architecture
The obvious reason to move parsing off the serving host would be CPU time. That reason turned out to be wrong, and measuring it produced the more interesting result.
A cache built from scratch takes 47 seconds at ~260,000 objects/second. The same work appended into an existing cache takes 12–13 minutes, at ~12,400/second — a 21× difference.
The cause is indexing: the cache builds its indexes when a snapshot is finalised, so every later append writes through them and the bulk-loader loses its advantage.
So the split is not about the 47 seconds. It is about keeping gigabytes of disk churn away from the machine running a live game server, and keeping the snapshot history — which grows by 1–2 GB per retained save and gets progressively more expensive to extend — on the workstation, while the server receives only what it serves.
The serving host still parses a save at startup, in about four seconds: roughly twenty endpoints read the in-memory store rather than the database. Only the expensive batch build moved.
Every save in this environment is a copy of the same world with slight testing drift. They therefore share one world identifier and differ by source and backup id, rather than being modelled as separate worlds. That distinction is not pedantry: splitting them would scatter one world's history into parallel timelines and make the comparison view unable to diff them, which is the only interesting question to ask of two copies.
Operations
Code and data ship separately. The code lane tars a whitelist, builds the image on the target, and polls for readiness. The data lane builds artifacts on the workstation, gates them, and installs them into the serving host's volume.
Both gates fail closed. The publish gate refuses unless the cache was built from the exact bytes the serving host will read — otherwise the database view and the in-memory view would describe different saves. It also refuses a snapshot with no rows, or one with no dictionary version recorded.
The lanes are not commutative. Published data carries corrected names, but the in-memory endpoints come from the serving host's own parse with whatever build it is running. Publishing onto an older build yields a viewer whose map and database disagree — right names in one, wrong in the other. Deploy, verify, then publish.
Archiving a snapshot as columnar Parquet with zstd: 113.7 MB against 1,196 MB live, written in about three seconds, lossless — every column of every row.
It also stays queryable in place, with no import step. A round trip read back all 9,155,594 objects and 406,511 inventory rows through a glob, with category counts identical to the live cache.
Reference
Everything quantified in this paper, with the world it was measured against.
| Quantity | Value | Notes |
|---|---|---|
| World size | 9,155,594 | objects, ComfyEra16, 1.27 GB, world version 37 |
| Prefab coverage before | 15.6% | 7,730,680 objects unnamed |
| Prefab coverage after | 99.5% | 49,385 unresolved across 129 hashes |
| Dictionary | 3,458 | entries, game 0.221.12, all validated at load |
| Parse rate | ~260,000/s | fresh cache build, 47 s total |
| Append rate | ~12,400/s | into an existing indexed cache, 12–13 min |
| Serve-mode parse | ~4 s | in-memory only, no cache write |
| Cache per snapshot | 1,196 MB | live DuckDB |
| Archive per snapshot | 113.7 MB | Parquet + zstd, lossless, 10.5× |
| Deploy duration | 1 m 43 s | image build on target, cache refresh, verify |
| Misclassified objects | ~573,000 | corrected by retiring six hash-set entries |
| Signs recovered | 577 | by the content-based rule, never previously detected |
Limits
Three claims in this codebase were confidently wrong, and each was wrong in the same way: an observation was recorded without the denominator that would have shown it was noise. A property on twelve objects out of forty thousand. A delta engine that reported zero because zero is what its arithmetic degenerates to. A duration copied from a parse timing into a document describing a very different operation.
None of these were careless. Each was a reasonable inference from a real observation. What they lacked was a ratio — and a ratio is cheap. The corrective adopted throughout is unglamorous: report fractions, not counts; make the comparison state whether it reconciles; and when a figure is measured, say against what.