Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 KiB
7 Days to Die — Cluster Canonical .nim ID Freeze
How the panel stops a 7DTD cluster's shared player saves from corrupting on cross‑server transfer (items "morphing" into other items, characters resetting to level 1 / naked). Read this before touching cluster
.nimfiles, the 7dtdentrypoint.shfreeze guard, the canonical, or regenerating a clustered world.Author: deployed live 2026‑06‑13 on cluster
cl_37e3a7f72cdc(Refuge). Companion code:modules/7dtd/entrypoint.sh(the guard),modules/7dtd/nim-freeze/rebuild.py(regenerate the canonical), andmodules/7dtd/nim-freeze/remap_7dt.py+REMAP_RUNBOOK.md(new‑world deco remap).
⚠️ CORRECTION / SECOND HALF OF THE PROBLEM (2026‑06‑13, later same day)
The freeze below fixes inventory (.ttp) transfers — correct and live. But it is
NOT sufficient for a freshly‑generated DIFFERENT world, and that's why insane/pvp
came up "online but unjoinable" with a DecoManager.TryAddToOccupiedMap NullReference.
Root cause (proven): a world also bakes its decorations into decoration.7dt
(and structures into multiblocks.7dt) by numeric block id, using the engine's
native runtime ids at generation time. The cluster's current runtime (RefugeBot
v1.2.39) assigns the tree‑deco band at ids 21895+, while the season10‑anchored
canonical assigns the same trees at 24141+ (season10's world was baked before a
RefugeBot rebuild shifted the band). Stamping the canonical over a new world's .nim
makes its native‑baked deco ids resolve to a null Block → NRE → world‑load
coroutine dies → never GameStartDone. season10/creative load clean only because their
deco was baked when runtime == canonical.
Fix for a new world = a one‑time per‑world deco remap (remap_7dt.py): translate
decoration.7dt/multiblocks.7dt block ids native → name → canonical, preserving the
high/rotation bits, then stamp the canonical. The engine preserves loaded deco ids on
save (only writes native at generation), so the remap is stable across reboots.
.7dt format: u8 ver=6, u32 count, count×17B recs; id = u16@+12 & 0x7FFF. The cluster
runtime native table is identical across all instances (one capture is authoritative).
Full step‑by‑step: nim-freeze/REMAP_RUNBOOK.md. insane (Yixacove) + pvp (Tuxeno)
were fixed this way and verified: GameStartDone, NRE=0, deco band 24141, on canon.
Permanent removal of the per‑world remap step: pin block ids explicitly in a top‑priority modlet (so runtime native == canonical) or freeze the RefugeBot build season10 was baked under. Until then, every NEW clustered world needs this one remap.
TL;DR
- 7DTD stores a player's inventory by numeric ID, not by name. Each world
bakes its own
name <-> idlookup tables:itemmappings.nimandblockmappings.nimin that world's save dir. - Our cluster shares one player save folder across servers that each run a
different world. The shared
.ttpcarries one world's numbers; every other world decodes them through a different table → glass block reads as car hood, etc. Severe mismatches fail to load → fresh level‑1 character, written back over the shared save = permanent wipe. - Fix: build ONE complete, season10‑anchored canonical id table and freeze
it onto every world in the cluster. Because it preserves season10's exact ids
and contains every block/item the mods can produce, every world decodes the
shared
.ttpidentically and the engine never appends → it stays frozen. - A small entrypoint guard re‑stamps the canonical on every boot, so it's self‑healing and survives recreates/regens.
The problem (root cause, proven by forensics)
- A player's
.ttpinventory region is a stream of 2‑byte numeric ids — no item/block name strings (names only appear later, for name‑keyed progression like perks/recipes, which is why level/skills survive a transfer but inventory/placed‑blocks do not). - Those numbers mean something only relative to the per‑world
.nimtables. The engine logsINF Block IDs with mapping/INF ItemIDs from Mappingon every world load — it resolves stored ids → names through that world's.nim. - The
.nimfiles are NOT shared — only thePlayer/folder is symlinked to/cluster/Player. Each world baked its table independently at genesis, so the same id maps to a different name per world. Live proof on the cluster:- block id 9243 =
steelShapes:cubeon season10 butawningShapes:cubeon the others. - item id 66/67 = the inverse swap
meleeHandPlayer <-> meleeHandZombie01.
- block id 9243 =
- Why identical mods don't save you: ids for un‑pinned/modded content are
auto‑assigned at world genesis in encounter order and frozen into that
world's
.nim. Two worlds generated at different times/states freeze different assignments even from byte‑identical XML. So mod‑syncing, mod symlinks, identical load order — all irrelevant. The divergence lives in the saves volume, not the mods. - The "naked / level 1" variant: the destination's table is missing ids the
.ttpreferences (e.g. season10 had 1888 item ids; a fresh RWG world's table had ~133). Unresolvable ids are dropped; enough drops and the character loads empty/level 1. Because the save is shared and written back, the wipe is permanent (.ttpand.ttp.bakboth overwritten).
This is the unsupported corner of 7DTD: The Fun Pimps disable cross‑world characters for exactly this reason. Sharing one inventory across servers running different worlds only works if every world uses an identical, frozen id table.
The fix: a complete, frozen canonical table on every world
What "canonical" means here
A single pair of .nim files that is:
- Anchored to season10 — every id season10 currently uses is preserved byte‑for‑byte. season10 authored the 87 shared inventories and has the richest/oldest table, so the existing saves already match it. This makes the freeze non‑destructive to season10's world and all existing inventories.
- Complete — contains every block and item the mods can ever produce:
- all named items + all 237
item_modifiernames (these serialize intoitemmappings.nimwhen installed — the missing‑21 of these was the live char‑wipe vector), - all named blocks + the entire
base:shapecross‑product (10 shape‑helper bases × every shape inshapes.xml) + the engine's authoritative block dump.
- all named items + all 237
Because it's complete, the engine never meets a name not already in the table →
the append branch never fires → the file is never rewritten → frozen.
Verified live on creative/season10: the .nim sha256 is byte‑identical before and
after boot + world‑load.
Final numbers (cluster cl_37e3a7f72cdc)
| file | count | sha256 (prefix) |
|---|---|---|
blockmappings.nim |
38,626 blocks (ids 0..63004) | 0a7db915… |
itemmappings.nim |
2,719 items (ids 1..3513) | fa4af1df… |
u16 ceiling is 65,535 — headroom remains.
Components & locations
| What | Where |
|---|---|
| Deployed canonical (the source of truth the guard reads) | figaro:/home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/{blockmappings.nim,itemmappings.nim} — this is the host side of the shared /cluster bind mount, so it appears as /cluster/canonical/ inside every member container. |
| The freeze guard | modules/7dtd/entrypoint.sh, the --- cluster canonical .nim freeze guard --- block. Baked into panel-7dtd:latest. |
Per‑world .nim (what gets stamped) |
<saves‑vol>/.local/share/7DaysToDie/Saves/<World>/<GameName>/{block,item}mappings.nim |
| Rebuild tool | modules/7dtd/nim-freeze/rebuild.py (re‑anchor) |
| Build artifacts / name registries (for a from‑scratch rebuild) | figaro:/home/refuge/nimcanon/ — tool/, registry/ (blocks_shapes_complete.txt, item_modifiers_all.txt, blocks_named_currentconfig.txt, …), canonical_final/, src/ (anchor copies). Persisted out of /tmp so a figaro reboot doesn't lose them. |
The entrypoint guard (the automation)
On every boot, after the cluster Player‑symlink setup, the entrypoint runs:
if [ -f /cluster/canonical/blockmappings.nim ] && [ -f /cluster/canonical/itemmappings.nim ] \
&& [ -d "$SAVE_BASE" ]; then
cp -f /cluster/canonical/itemmappings.nim "$SAVE_BASE/itemmappings.nim"
cp -f /cluster/canonical/blockmappings.nim "$SAVE_BASE/blockmappings.nim"
fi
SAVE_BASE=Saves/$CLUSTER_PLAYER_SAVE(set by the Cluster‑tab "Shared player world" picker), or the literalSaves/$GameWorld/$GameNamefallback.- Only fires for cluster members that have a
/cluster/canonical/present → non‑cluster servers and other clusters are untouched. - Idempotent + self‑healing: a complete canonical never triggers an append, so re‑stamping the same bytes every boot is a no‑op; if a stray append ever happened, the next boot resets it.
You'll see this line in the boot log when it works:
[panel-7dtd] cluster <id>: stamped canonical id maps over .../<World>/<GameName> (frozen, divergence-proof)
Is it automated? (what's hands‑off vs. operator action)
- Hands‑off: every existing frozen world stays frozen forever; every
boot/recreate/
/rebuildre‑applies the canonical automatically. - One operator step for a NEW world: when a clustered server regenerates an
RWG world (new seed), the new world's folder name is seed‑derived and
unknown until after gen, so the guard can't target it until you set the
Cluster‑tab Shared player world (
CLUSTER_PLAYER_SAVE). Set that (and the Region Medicworld) to the new world and the guard freezes it on the next boot. This is the same one‑click step a normal cluster join already needs. - Rebuild needed only if mods change (see below).
Future hardening (not yet done): make the guard/symlink auto‑detect the active world dir (newest
Saves/*/<GameName>/with amain.ttw) so even RWG regens need zero manual settings. Deliberately left as an explicit Cluster‑tab setting for now.
Operating
Add a clustered server / regenerate a clustered world (RWG)
Goal: fresh world, frozen on the canonical, sharing the player folder. (Fresh worlds are clean — worldgen places vanilla blocks whose ids already match the canonical, so no morph.)
- New map: change the seed —
POST /api/instances/<id>/env-configbody{"updates":{"world_seed":"<new>"}}. The engine gens a new seed‑named world. - Find the new world dir (newest
Saves/*/<GameName>/main.ttw). - Point the cluster settings at it:
POST .../env-config{"updates":{"CLUSTER_PLAYER_SAVE":"<NewWorld>/<GameName>"}}→ recreate → guard stamps it + symlinksPlayer -> /cluster/Player.- Write
region-medic.json"world": "<NewWorld>/<GameName>"into the saves volume (keepenabled/keep/discord_channel).
- Confirm the boot log shows the "stamped canonical" line and the world
.nimsha256 == the cluster canonical.
After a mod change (new blocks/items added) — REBUILD the canonical
The canonical is a static snapshot of the merged config's name universe. New mods add names it doesn't have, so those would append per‑world again.
- Re‑harvest the complete name set (the two ultracode workflows did this; the
essential outputs are in
~/nimcanon/registry/):- blocks:
<base>:<shape>cross‑product fromshapes.xml× the 10 shape‑helper bases, ∪ the engine's authoritative block dump (exportcurrentconfigs/ runtimeBlocklist), ∪ named blocks. - items: every
<item name=>∪ every<item_modifier name=>(grep -rhoE '<item_modifier name="[^"]+"' Data/Config/item_modifiers.xml Mods/*/Config/item_modifiers.xml).
- blocks:
- STOP season10, copy its current
.nimas the anchor, runrebuild.py(see its header) to produce a re‑anchored, extended, complete canonical. - Verify
GATE PASS: True(0 anchor ids altered, no dup id/name, complete, max id < 65536). - Copy into
…/<cluster_id>/canonical/(chmod 644) and/rebuildeach member.
Verify a server is frozen
# .nim on disk == the cluster canonical:
docker run --rm -v panel-<id>-saves:/sv:ro debian:12-slim \
sha256sum "/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>/blockmappings.nim"
# vs:
sha256sum /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/blockmappings.nim
# and the boot log:
docker logs panel-<id> 2>&1 | grep "stamped canonical"
The real test is in‑game: transfer a player between two members and confirm glass stays glass, inventory intact, no level reset.
Troubleshooting / gotchas
/rebuildleft the server STOPPED. If an instance was stopped before/rebuild, the recreate suppresses autostart and leaves itCreated/stopped. Just Start it — the guard runs on boot.- Guard didn't stamp / wrong dir.
CLUSTER_PLAYER_SAVEis empty or stale → for RWG the fallbackSaves/RWG/<GameName>doesn't exist, so the guard skips. Set the Cluster‑tab Shared‑player‑world to the real (seed‑derived) world. - season10 grew between snapshot and stamp. season10's
.nimappends as players place new shapes (we saw 12259 → 12263 in a few hours). Always re‑anchor (rebuild.py) against season10's CURRENT, STOPPED.nimimmediately before stamping season10. Once season10 is frozen on the complete canonical it stops growing. - Stamp only while the target is STOPPED, as the runtime uid (
1000:1000), mode0644. An unreadable.nimmakes the engine silently gen a fresh table = full remap. - Old orphaned worlds (e.g.
Tefasizi County,Kopaneke Territory) are left in the saves volumes after an RWG regen — safe to delete to reclaim space.
If we need to pivot (alternatives, ranked)
If the freeze ever proves unworkable (e.g. a mod that registers ids at runtime — none currently do; all 7DTD content here is static XML):
- One shared world across the whole cluster (same GameWorld+seed+GameName).
One world → one
.nim→ impossible to diverge. Cost: every server is the SAME map (defeats distinct‑experience servers). Simplest and bulletproof. - Don't carry numeric‑id state across worlds. Share only name‑keyed progression (level/skills/perks/recipes — these already transfer cleanly) and strip bag/belt/equipment on join to a world that didn't author the inventory (an admin/RefugeBot hook). Players keep their character, not carried items. Preserves distinct worlds.
- Re‑encode on transfer (true but fiddly): decode the
.ttpinventory through the SOURCE world's.nimand re‑encode through the DESTINATION's, dropping ids the destination lacks. Must run while the player is offline. - The current approach (freeze a complete canonical) is strictly better than
"sync the
.nimacross different worlds" — never try that bare, because the.nimis also the codebook for each world's OWN placed blocks: swapping it on a world with existing modded builds morphs that world's terrain (that's why insane/pvp got fresh worlds instead of an in‑place stamp).
Key facts to remember
- Inventory/placed blocks = numeric id; level/skills/perks/recipes = name‑keyed (the latter survive any transfer).
.nimis written only on table growth, not per save — a complete table is therefore frozen.- The canonical must be anchored to season10 (the authoring world) or existing inventories morph.
- New RWG world folder names are seed‑derived; you must set
CLUSTER_PLAYER_SAVE+ medicworldto the real name after gen. - The deployed canonical lives in the shared cluster dir (
/cluster/canonical) so one copy serves every member and the guard is purely local file ops.