Files
panel/modules/7dtd/CANONICAL_NIM_FREEZE.md
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

300 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 7 Days to Die — Cluster Canonical `.nim` ID Freeze
> How the panel stops a 7DTD cluster's **shared player saves** from corrupting on
> crossserver 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 20260613 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` (newworld deco remap).
---
## ⚠️ CORRECTION / SECOND HALF OF THE PROBLEM (20260613, later same day)
The freeze below fixes **inventory** (`.ttp`) transfers — correct and live. But it is
**NOT sufficient for a freshlygenerated 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 treedeco band at ids **21895+**, while the season10anchored
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 nativebaked deco ids resolve to a **null Block** → NRE → worldload
coroutine dies → never `GameStartDone`. season10/creative load clean only because their
deco was baked when runtime == canonical.
**Fix for a new world = a onetime perworld 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 stepbystep: `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 perworld remap step: pin block ids explicitly in a toppriority
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 level1 character, written
back over the shared save = **permanent wipe**.
* **Fix:** build ONE *complete, season10anchored* 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** restamps the canonical on every boot, so it's
selfhealing and survives recreates/regens.
---
## The problem (root cause, proven by forensics)
1. A player's `.ttp` inventory region is a stream of **2byte numeric ids** — no
item/block name strings (names only appear later, for namekeyed progression
like perks/recipes, which is why **level/skills survive** a transfer but
**inventory/placedblocks do not**).
2. Those numbers mean something only relative to the **perworld** `.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 unpinned/modded content are
**autoassigned 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 byteidentical XML. So modsyncing, 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 crossworld
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
**byteforbyte**. season10 authored the 87 shared inventories and has the
richest/oldest table, so the existing saves already match it. This makes the
freeze **nondestructive 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 missing21 of these was the live
charwipe vector),
* all named blocks + the **entire `base:shape` crossproduct** (10 shapehelper
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 byteidentical before and
after boot + worldload.
### 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`. |
| **Perworld `.nim`** (what gets stamped) | `<savesvol>/.local/share/7DaysToDie/Saves/<World>/<GameName>/{block,item}mappings.nim` |
| **Rebuild tool** | `modules/7dtd/nim-freeze/rebuild.py` (reanchor) |
| **Build artifacts / name registries** (for a fromscratch 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 Playersymlink 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 Clustertab "Shared player
world" picker), or the literal `Saves/$GameWorld/$GameName` fallback.
* Only fires for cluster members that have a `/cluster/canonical/` present →
noncluster servers and other clusters are untouched.
* **Idempotent + selfhealing**: a complete canonical never triggers an append, so
restamping the same bytes every boot is a noop; 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 handsoff vs. operator action)
* **Handsoff:** every existing frozen world stays frozen forever; every
boot/recreate/`/rebuild` reapplies 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 **seedderived and
unknown until after gen**, so the guard can't target it until you set the
Clustertab **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 oneclick step a normal cluster *join* already needs.
* **Rebuild needed only if mods change** (see below).
> Future hardening (not yet done): make the guard/symlink **autodetect** the
> active world dir (newest `Saves/*/<GameName>/` with a `main.ttw`) so even RWG
> regens need zero manual settings. Deliberately left as an explicit Clustertab
> 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 seednamed 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 perworld again.
1. Reharvest the complete name set (the two ultracode workflows did this; the
essential outputs are in `~/nimcanon/registry/`):
* blocks: `<base>:<shape>` crossproduct from `shapes.xml` × the 10 shapehelper
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 reanchored, 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 ingame: 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 Clustertab Sharedplayerworld to the real (seedderived) 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
reanchor (`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 distinctexperience servers). Simplest and bulletproof.
2. **Don't carry numericid state across worlds.** Share only namekeyed
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. **Reencode on transfer** (true but fiddly): decode the `.ttp` inventory
through the SOURCE world's `.nim` and reencode 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 inplace stamp).
---
## Key facts to remember
* Inventory/placed blocks = **numeric id**; level/skills/perks/recipes =
**namekeyed** (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 **seedderived**; 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.