panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
# 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 `.nim` files, the 7dtd
|
||||
> `entrypoint.sh` freeze 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), and
|
||||
> `modules/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 <-> id` lookup tables: `itemmappings.nim` and
|
||||
`blockmappings.nim` in that world's save dir.
|
||||
* Our cluster **shares one player save folder** across servers that each run a
|
||||
**different world**. The shared `.ttp` carries 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 `.ttp` identically 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)
|
||||
|
||||
1. A player's `.ttp` inventory 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**).
|
||||
2. Those numbers mean something only relative to the **per‑world** `.nim` tables.
|
||||
The engine logs `INF Block IDs with mapping` / `INF ItemIDs from Mapping` on
|
||||
every world load — it resolves stored ids → names **through that world's
|
||||
`.nim`**.
|
||||
3. The `.nim` files are **NOT shared** — only the `Player/` 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:cube` on season10 but `awningShapes:cube`
|
||||
on the others.
|
||||
* item id **66/67** = the inverse swap `meleeHandPlayer <-> meleeHandZombie01`.
|
||||
4. **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.
|
||||
5. The "naked / level 1" variant: the destination's table is missing ids the
|
||||
`.ttp` references (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 (`.ttp` *and* `.ttp.bak` both 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_modifier` names** (these serialize into
|
||||
`itemmappings.nim` when installed — the missing‑21 of these was the live
|
||||
char‑wipe vector),
|
||||
* all named blocks + the **entire `base:shape` cross‑product** (10 shape‑helper
|
||||
bases × every shape in `shapes.xml`) + the engine's authoritative block dump.
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
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 literal `Saves/$GameWorld/$GameName` fallback.
|
||||
* 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/`/rebuild` re‑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 Medic `world`) 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 a `main.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**.)
|
||||
|
||||
1. **New map:** change the seed — `POST /api/instances/<id>/env-config` body
|
||||
`{"updates":{"world_seed":"<new>"}}`. The engine gens a new seed‑named world.
|
||||
2. Find the new world dir (newest `Saves/*/<GameName>/main.ttw`).
|
||||
3. **Point the cluster settings at it:**
|
||||
* `POST .../env-config` `{"updates":{"CLUSTER_PLAYER_SAVE":"<NewWorld>/<GameName>"}}`
|
||||
→ recreate → guard stamps it + symlinks `Player -> /cluster/Player`.
|
||||
* Write `region-medic.json` `"world": "<NewWorld>/<GameName>"` into the saves
|
||||
volume (keep `enabled`/`keep`/`discord_channel`).
|
||||
4. Confirm the boot log shows the "stamped canonical" line and the world `.nim`
|
||||
sha256 == 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.
|
||||
|
||||
1. Re‑harvest the complete name set (the two ultracode workflows did this; the
|
||||
essential outputs are in `~/nimcanon/registry/`):
|
||||
* blocks: `<base>:<shape>` cross‑product from `shapes.xml` × the 10 shape‑helper
|
||||
bases, ∪ the engine's authoritative block dump (`exportcurrentconfigs` /
|
||||
runtime `Block` list), ∪ 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`).
|
||||
2. STOP season10, copy its current `.nim` as the **anchor**, run `rebuild.py`
|
||||
(see its header) to produce a re‑anchored, extended, complete canonical.
|
||||
3. Verify `GATE PASS: True` (0 anchor ids altered, no dup id/name, complete,
|
||||
max id < 65536).
|
||||
4. Copy into `…/<cluster_id>/canonical/` (chmod 644) and `/rebuild` each member.
|
||||
|
||||
### Verify a server is frozen
|
||||
|
||||
```sh
|
||||
# .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
|
||||
|
||||
* **`/rebuild` left the server STOPPED.** If an instance was stopped *before*
|
||||
`/rebuild`, the recreate suppresses autostart and leaves it `Created`/stopped.
|
||||
Just **Start** it — the guard runs on boot.
|
||||
* **Guard didn't stamp / wrong dir.** `CLUSTER_PLAYER_SAVE` is empty or stale →
|
||||
for RWG the fallback `Saves/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 `.nim` appends as
|
||||
players place new shapes (we saw 12259 → 12263 in a few hours). **Always
|
||||
re‑anchor (`rebuild.py`) against season10's CURRENT, STOPPED `.nim` immediately
|
||||
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`),
|
||||
mode `0644`. An unreadable `.nim` makes 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):
|
||||
|
||||
1. **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.
|
||||
2. **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.
|
||||
3. **Re‑encode on transfer** (true but fiddly): decode the `.ttp` inventory
|
||||
through the SOURCE world's `.nim` and re‑encode through the DESTINATION's,
|
||||
dropping ids the destination lacks. Must run while the player is offline.
|
||||
4. The current approach (**freeze a complete canonical**) is strictly better than
|
||||
"sync the `.nim` across different worlds" — never try that bare, because the
|
||||
`.nim` is 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).
|
||||
* `.nim` is 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` + medic `world` to 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.
|
||||
Reference in New Issue
Block a user