panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 658bda1d24
2160 changed files with 300413 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 7DTD module ships shell scripts that run as the container entrypoint.
# A CRLF here breaks the container's /bin/bash interpreter line and
# `set -euo pipefail`. Force LF regardless of the editor's platform.
*.sh text eol=lf
entrypoint.sh text eol=lf
+299
View File
@@ -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
> 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.
+341
View File
@@ -0,0 +1,341 @@
# 7 Days to Die — Cluster Playbook
> The single operator + architecture doc for the Refuge 7DTD cluster: how shared
> characters/inventory work, how new servers auto-fix themselves on create, the
> one rule you must never break, and exactly what we do at the 3.0 wipe.
>
> Deep "why" lives in `CANONICAL_NIM_FREEZE.md` (root cause) and the manual
> fallback in `nim-freeze/REMAP_RUNBOOK.md`. **This doc is the one to read first.**
---
## 1. The one thing to understand
7DTD stores everything — your inventory, the blocks in the world, the trees —
by a **numeric id**, not by name. The engine hands out those numbers automatically
when it boots, and the assignment **drifts** whenever the *build* changes. "Build"
= the exact set of mods + their load order + the RefugeBot DLL version + the engine
version. Nothing else matters.
* **Same build everywhere → same numbers everywhere.** Inventory transfers cleanly,
worlds load. (Proven: two cluster servers on the same build produce byte-identical
id tables.)
* **Different builds → different numbers → "morph".** A glass block saved as id 21895
on one server reads as a car hood on another that put a different block at 21895.
Characters can even reset to level 1.
Everything below — the canonical table, the stamp, the decoration remap, the
auto-provision — exists for **one** reason: to bridge servers/worlds that were
baked under *different* builds so they all agree on the numbers.
---
## 2. The golden rule (non-negotiable)
> **Every server in the cluster must run the byte-identical build at all times.**
Same mods, same load order, same RefugeBot build, same game version — on **all**
members, always. Break this and inventory morphs. The build *can* evolve over time,
but only as a **coordinated, all-servers-at-once maintenance event** (see §6), never
one server at a time.
---
## 3. The two moving parts
| Part | What it is | Who maintains it |
|---|---|---|
| **The canonical** | One frozen `name ↔ id` table (`blockmappings.nim` + `itemmappings.nim`) every server stamps on, so the **shared player inventory** (`.ttp`) decodes identically everywhere. Lives at `/cluster/canonical/` (host: `…/7dtdcluster/<id>/canonical/`). | `nim-freeze/rebuild.py` (re-anchor / extend) |
| **The decoration remap** | A world bakes its **decorations** (`decoration.7dt` / `multiblocks.7dt`) with the engine's *native* ids at generation. Those must be rewritten to the canonical's ids or the world NREs on load. | `nim-freeze/remap_7dt.py`, now **automatic** (see §4) |
Why both: the canonical fixes **inventory**, the remap fixes **the world the
decorations live in**. The deep mechanics are in `CANONICAL_NIM_FREEZE.md`.
---
## 4. How it works now — auto-provision on create (the steady state)
You don't run any of the remap tooling by hand anymore. The game container's
entrypoint + the agent do it for you. A brand-new clustered world becomes
canon-compatible **on its own**, in two boots:
1. **Gen boot.** You create the server (cluster picked), Update (downloads the
game), and Start. The engine generates the world and bakes its decorations
with native ids. The world is up but *not yet aligned* to the cluster.
2. **Align boot (automatic).** The agent's auto-provision watcher notices the
fresh world's decorations are in the native band and **bounces the container
once**. On that boot, the entrypoint remaps `decoration.7dt` + `multiblocks.7dt`
native → canonical (using the world's own native table), drops a
`.canon-provisioned` marker, and stamps the canonical. The world comes back
**clean, on canon, joinable, transfer-safe.**
**What you see (visibility):** in the server's **Console**
`[panel] auto-provision: aligning the new world's decorations to the cluster…`
then `remapped decoration.7dt native->canonical` then
`decorations aligned to cluster canonical — world is now canon-compatible` then
`StartGame done`. The card briefly shows **"aligning decorations to cluster…"**.
**It is a strict no-op for everything else:** existing canon worlds
(season10/creative/insane/pvp) and non-cluster servers never enter the remap
branch (their decorations are already in the canon band), so recreating/restarting
them does nothing. The remap is **stable** — once a world is canon, it stays canon
across every save and reboot (the engine preserves loaded decoration ids; it only
writes native ids at *generation*).
**The backstop:** even if the auto-bounce is ever missed, the entrypoint aligns the
world on its *next* ordinary restart. The auto-bounce just makes it zero-touch.
### Files that implement it (for the next engineer)
* `modules/7dtd/entrypoint.sh` — the remap block (right before the canonical-stamp
guard) + the `.panel-save-base` writer. Gated on the **decoration band** only.
* `modules/7dtd/Dockerfile` — adds `python3-minimal` + the vendored remapper.
* `modules/7dtd/nim-freeze/remap_7dt.vendored.py` — self-contained in-container remapper.
* `agent/internal/dispatch/decoremap_provision.go` — the watcher (single-flight,
probes the running container, bounces once on a native-band world).
---
## 5. Adding a new server (operator steps)
1. **Add server** in the panel, module **7 Days to Die**, and **pick the cluster**
in the create form. (This wires the shared Player folder + the canonical mount.)
2. **Update** (installs the game) → **Start**. Let it generate the world.
3. **RWG worlds only:** once the world has generated, set the **"Shared player world"**
in the **Cluster tab** to the new (seed-named) world. The picker writes
`CLUSTER_PLAYER_SAVE` and triggers a recreate, so the entrypoint stamps + aligns the
right dir. **Also point Region Medic at the same `<World>/<GameName>`** (Cluster tab →
Region Medic card), or Medic keeps healing the old, now-deleted world. Until both are
set, alignment can't target the world.
4. That's it. The world auto-aligns to the cluster (watch the Console). When you see
`StartGame done` after the alignment bounce, it's joinable and transfer-safe.
> **Is the new world done? Don't let players join until all three pass:**
> 1. Console shows `StartGame done` *after* the `auto-provision: aligning…` line.
> 2. Save dir has a `.canon-provisioned` file.
> 3. Decorations read in the **24xxx** band — verify it yourself (read-only):
> ```sh
> docker run --rm -v panel-<inst>-saves:/sv:ro debian:12-slim sh -c \
> 'f="/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>/decoration.7dt"; \
> echo "first deco id = $(( $(od -An -tu2 -j17 -N2 "$f") & 0x7FFF ))"'
> ```
> `>= 24000` = canon (good). `< 24000` = still native (NOT aligned — do not let players in).
>
> **If after 2 boots there's still no `.canon-provisioned` / the band is native** (the
> §12 #3 case): do **one** more panel **Restart** — the entrypoint backstop aligns it on
> that boot — and re-check. Still native after that → manual repair via
> `nim-freeze/REMAP_RUNBOOK.md`.
---
## 6. Changing mods (a maintenance event, not a casual edit)
Because of the golden rule, a mod change is a **whole-cluster** operation:
1. Update **every** server's mods together (same set, same order, same RefugeBot
build). Never one server at a time.
2. **Rebuild the canonical** so it contains the new names (else they append per-world
again → morph). This is **two** steps — ⚠️ **`rebuild.py` does NOT discover new mod
content; it only re-anchors an existing name universe.** Doing 2b without 2a silently
produces an incomplete canonical and re-introduces the exact wipe bug this system
prevents.
* **2a — Re-harvest the complete name universe** (the hard part). Blocks = the
`<base>:<shape>` cross-product (`shapes.xml` × the 10 shape-helper bases) the
engine's authoritative block dump (telnet `exportcurrentconfigs`, or the runtime
`Block` list) every named block; items = every `<item name=>` every
`<item_modifier name=>`. The original harvest ran as two ultracode workflows; its
inputs/outputs live on figaro at `~/nimcanon/registry/` but **the generator is not
committed** — exact greps/commands are in `CANONICAL_NIM_FREEZE.md` → "After a mod
change". **Do not attempt a mod change without re-running this harvest.**
* **2b — Re-anchor** against season10's CURRENT, STOPPED `.nim` (the
`West Apeeni Mountains/MyGame` anchor — verify 38,626/2,719+ first, §13) with the new
universe: `nim-freeze/rebuild.py` preserves every existing id and appends new names
at fresh ids (so old worlds + the ~88 shared characters still decode). Verify
`GATE PASS: True`, then copy into `/cluster/canonical/` (chmod 644).
3. **Re-stamp every member** so they pick up the new canonical — a panel **Restart**
is enough (the entrypoint re-stamps from `/cluster/canonical` on every boot; season10
too, by Restart only — never Rebuild). New worlds genned after the change auto-align
(§4); existing worlds keep working (their content + the canonical's existing ids are
unchanged).
Adding content is safe (append). Removing/reordering content is where ids shift —
that's exactly what a **wipe** (§7) resets cleanly.
---
## 7. The 3.0 wipe roadmap (the easy button)
A version wipe is the moment to make the whole thing **bulletproof and remap-free**,
because you're discarding all the old worlds anyway. Plan:
1. **Freeze + hash the 3.0 build.** Lock the exact mod set + load order + RefugeBot DLL +
engine version, and record a hash of the artifact set (e.g. `tar c Mods/ | sha256sum`)
into a committed `3.0-build.lock`. That snapshot *is* the contract going forward.
2. **Wipe all worlds — through the panel, never docker CLI.** Per server: delete the old
`Saves/<World>/<GameName>` dirs (mount the saves volume, per the runbook's volume-access
pattern) and set a fresh `world_seed` via `env-config`, or delete-preserve-volumes +
recreate. Characters too — `.ttp` is build-dependent; a wipe is a fresh start for
inventories (carrying them across a build change needs the `.7rg`/`.ttp` codec work in §8).
3. **Re-anchor the canonical to the LIVE 3.0 runtime.** "Capture the current runtime's
table" = the `REMAP_RUNBOOK.md` **"CAPTURE the world's native table"** sub-procedure: on
a fresh 3.0 world, Stop → move the canonical aside (back it up first!) → write the 8-byte
empty `.nim` → Start (the engine writes the world's authoritative native table) → copy it
out → restore the canonical. Feed that as the new anchor to `rebuild.py` together with a
freshly-harvested 3.0 universe (§6 step 2a). Now **`canonical == runtime`** and the gap
that forces per-world remapping is gone.
4. **From then on, fresh maps just work with zero remap** — every world is born from the
same starting numbers. The auto-provision/stamp machinery stays in place as a harmless
safety net; it simply has nothing to fix.
5. Only a *future* mod change re-introduces the need to rebuild the canonical (§6).
> ⚠️ **The 3.0 wipe is irreversible and depends on the (currently uncommitted) §6 harvest
> workflow.** Do NOT execute it from this strategy alone — when 3.0 lands, write a dedicated,
> tested 3.0 runbook with the exact harvest + capture + wipe commands first. The plan above
> is sound; the runnable commands are the gap to close before you pull the trigger.
Net: **today** = same-mods rule + auto-provision handles new servers; **at 3.0** =
clean re-anchor and new maps need no special handling at all. It's a ~one-evening job
when 3.0 lands; nothing to prep now.
The truly permanent fix that removes even the maintenance events: pin every block/item
to an explicit `id=` in a top-priority modlet so the runtime can never drift from the
canonical (untested on this engine version — a worthwhile experiment), or freeze the
RefugeBot build the canonical was anchored under.
---
## 8. Known limits / frontier
* **RWG needs one pick.** A random-gen world's save folder name is seed-derived and
unknown until after gen, so the operator sets "Shared player world" once (§5 step 3).
Auto-align kicks in after that.
* **Regions + player `.ttp` are not remapped.** The codecs for `Region/*.7rg`
(explored chunk blocks) and `Player/*.ttp` (inventory) weren't cracked, so alignment
is done on a **fresh, 0-region world before exploration** (which is exactly when a
new server provisions). The 87 shared characters are fine (already canon-written).
Solving these codecs (decompile `Assembly-CSharp.dll`) is what would let you migrate
characters across a build change instead of wiping them.
---
## 9. Quick reference
| Want to… | Do this |
|---|---|
| Add a server | §5 (auto-aligns; RWG needs the one Cluster-tab pick) |
| Confirm a world is aligned | save dir has `.canon-provisioned`; decorations in 24xxx band |
| Change mods | §6 (all servers together + rebuild canonical) |
| Wipe for 3.0 | §7 (freeze build → wipe → re-anchor canonical to live runtime) |
| Manually fix a stuck world | `nim-freeze/REMAP_RUNBOOK.md` |
| Understand the root cause | `CANONICAL_NIM_FREEZE.md` |
| Re-anchor / extend the canonical | `nim-freeze/rebuild.py` |
| Deploy controller / agent / module | §10 (+ the `panel-deploy` skill) |
| Recover a wedged agent (config fields won't load / 504s) | restart `panel-agent` on figaro — §10 / §11 |
| Known issues & open work | §12 (season10 console, the "setting up" indicator, probe-path) |
| What's deployed vs. uncommitted | §13 |
---
## 10. Operational architecture & deploy (for the next engineer)
**Where it runs**
| Piece | Host | Notes |
|---|---|---|
| **Controller** (dashboard + gRPC) | kaiten `<user>@<controller-host>` | binary `/home/refuge/panel/bin/controller`, unit `panel-controller.service`, HTTP `:8180`, gRPC `:8443` |
| **Agent** (owns the docker game containers) | figaro `<user>@<agent-host>` | binary `/home/refuge/panel/bin/agent`, unit `panel-agent.service`, dials the controller |
| **Cluster shared dir** (the `/cluster` bind) | figaro `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/` | holds `canonical/` + `Player/`; bind-mounted into every member as `/cluster` |
| **Game containers** | figaro, image `panel-7dtd:latest` | named `panel-<instance>` (e.g. `panel-rg-season-10`) |
SSH everywhere with `~/.ssh/bubbly_ed25519`, user `refuge`. **The full deploy recipe is the `panel-deploy` skill — follow it.** The NOPASSWD sudoers is exact-match (single-service `systemctl` forms only).
**Panel access & conventions (read before driving anything)**
* **Dashboard:** `http://<controller-host>:8180` on the LAN (controller HTTP). Log in with your panel account. On kaiten itself the `http://127.0.0.1:8180/api/...` routes are unauthenticated — that's what the read-only diagnostic `curl`s in this doc use.
* **Server name → ids:** an instance's id is its slug. "Season 10" ⇒ instance id `rg-season-10` ⇒ container `panel-rg-season-10` ⇒ saves volume `panel-rg-season-10-saves`. List them all (with status) via `GET /api/instances`. Full live table in §13.
* **UI = API:** the `POST /api/instances/<id>/{env-config,start,stop,rebuild}` calls in these docs are exactly what the dashboard's Cluster-tab / Maintenance buttons fire. Use either — just target the right `<id>`.
* **"Stop / Start / Restart" always means the PANEL action** (agent-owned, clean shutdown). **NEVER `docker stop/start` a `panel-*` container** — the agent owns lifecycle + state reconciliation. Confirm a container is stopped via the grey card, or read-only `docker inspect -f '{{.State.Running}}' panel-<id>``false`. (Read-only `docker exec/logs/inspect` and `docker run` sidecars are fine.)
* **"Restart" vs "Rebuild" (load-bearing):** Restart = panel Stop then Start, **same image** — safe for any server *including season10*. **Rebuild/Recreate = adopts `panel-7dtd:latest`** — needed to pick up a module/image change, but **forbidden for `rg-season-10`** (it must stay on its old image; §13).
* **New entrypoint env vars MUST be declared in `module.yaml` `env:`** or the agent silently drops them (symptom: the var is empty at runtime even though you set it). `CLUSTER_ID` + `CLUSTER_PLAYER_SAVE` are already declared — add any new `CLUSTER_*` / provisioning var there too.
**Three deployable pieces**
1. **Controller** (Go + the dashboard HTML in `controller/cmd/controller/static/{new,index}.html`) — `GOOS=linux GOARCH=amd64 go build -o bin/controller-linux ./controller/cmd/controller`, scp to kaiten `bin/controller.new`, bak + swap + `systemctl restart panel-controller.service`.
* ⚠️ **After every controller deploy, HARD-refresh the browser (Ctrl-Shift-R).** A normal refresh serves the *old* cached JS against the new backend and the page half-breaks (fields won't load, console blank). It looks exactly like a real outage but is just stale cache — rule this out first.
2. **Agent** (Go) — `… go build -o bin/agent-linux ./agent/cmd/agent`, scp to figaro, bak + swap + `systemctl restart panel-agent.service`.
* Restarting the agent does **NOT** stop the game containers — it re-attaches/rehydrates them cleanly (stats + rcon + log streams re-open). Safe anytime; it is also the **recovery** for a wedged agent (§11).
3. **Module** (`modules/7dtd/{entrypoint.sh,Dockerfile,nim-freeze/remap_7dt.vendored.py}`) — sync the module dir Windows → kaiten → figaro, then build the image **on figaro** (where the containers run; kaiten holds the source-of-truth copy but builds nothing):
```sh
# from the Windows repo C:\Users\dbled\sources\panel :
scp -r modules/7dtd/ <user>@<controller-host>:/home/refuge/panel/modules/
ssh <user>@<controller-host> 'rsync -av /home/refuge/panel/modules/7dtd/ <user>@<agent-host>:/home/refuge/panel/modules/7dtd/'
ssh <user>@<agent-host> 'cd /home/refuge/panel/modules/7dtd && docker build -t panel-7dtd:latest .'
```
Existing containers **pin to the old image ID** — a container only adopts the new image via **Settings → Maintenance → Rebuild** (or delete-preserve-volumes + recreate).
* ⚠️ **Never Rebuild/recreate `rg-season-10`.** It is the live released server *and* the canonical anchor; it must stay on its current (old) image. `.Config.Image` shows the name `:latest` for every container — verify by image **ID**, not name.
**Do NOT `docker stop/start` a `panel-*` game container from the CLI** — let the panel do it (the agent owns lifecycle + state reconciliation). Read-only `docker exec`, `docker logs`, `docker inspect`, and `docker run` sidecars are fine.
---
## 11. Incident log & landmines
### 2026-06-13 — agent-wide hang (ExecCapture connection leak) ✅ FIXED + HARDENED
* **Symptom:** config fields wouldn't load on **any** server (the durable `serverconfig.xml` editor → `/api/instances/{id}/files/read` returned **HTTP 504 "timeout waiting for agent response"**); one server's console went blank; the agent was **idle (0% CPU) but unresponsive to file ops**; no panic.
* **Root cause:** the auto-provision watcher (`agent/internal/dispatch/decoremap_provision.go`) polls a running container with a docker exec (`DockerRuntime.ExecCapture`) every 20s. `ExecCapture` ran `stdcopy.StdCopy(…att.Reader)` on the hijacked exec stream **with no `ctx` enforcement on the Read**. When it caught the just-bounced `cluster-seed-test` mid-restart, the read blocked **forever** and leaked a docker SDK connection each cycle → the SDK client's connection pool exhausted → **every** later docker op (`CopyFileFromContainer` for config reads, a log-stream re-attach) hung. It is **not** a Go-mutex deadlock (the only mutex in `docker.go` — `hijackedStream.mu` — just guards the stdio-attach `Close()` and is unrelated); it's connection-pool starvation.
* **Recover:** `ssh <user>@<agent-host> 'sudo -n /usr/bin/systemctl restart panel-agent.service'` — clears the leaked connections; game containers keep running. **Confirm recovery:** re-open any server's Config tab (fields load within a few seconds), or on kaiten `curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8180/api/instances/rg-season-10/files/read?path=/game-saves/serverconfig.xml'` → expect **200**, not 504.
* **Permanent fix (shipped):** `ExecCapture` now runs `StdCopy` on a goroutine and `select`s on `ctx.Done()`; on timeout it `att.Close()`s to unblock the read, then drains the goroutine before reading the buffers (race-free). On the timeout path it returns the partial stdout/stderr with exit code `-1` and `exec copy: <ctx err>`, so callers can tell a wedged exec from a clean one. An exec can no longer hang the whole agent.
* **Landmine for the future:** *any* agent docker exec/stream read that ignores `ctx` can leak connections and wedge the entire agent the same way. If you add exec-based features, bound the read by `ctx`. (The log-stream demux at `docker.go:271` is a long-lived `Follow` stream — a different model, closes on the docker `rc`'s EOF/ctx — and is fine.)
* **Recognize it next time:** `files/read` 504s for *every* server + agent at 0% CPU + no crash ⇒ restart the agent first, then hunt for a new unbounded docker read.
---
## 12. Known issues & open work
1. **season10's panel console is blank** (separate from §11; pre-existing). season10's docker json-log has grown to **13 MB** and it is actively spamming exceptions: `No ItemClass entry for type 678xx` (×611) + `Log:Exception` / `ThreadManager:UpdateMainThreadTasks`. Docker's own `--follow --tail 400` serves season10 instantly and the other four servers stream fine, but the agent's SDK log-follow **stalls on season10's stream before forwarding a single line** (blocked-inside, not thrashing — no "log stream ended" retries). Re-attaching (two agent restarts) did **not** fix it. It will likely clear on a **season10 container restart** — a plain panel **Stop → Start**, which keeps season10 on its current image and is safe; this is **not** a Rebuild/recreate (that would adopt `panel-7dtd:latest` and break the canonical anchor — forbidden, see §10/§13). It may re-blank once the exception spam refills the log. **Durable fix:** resolve the `No ItemClass entry` item-config gap (kills the console stall, the log bloat, *and* the exceptions at once), **or** harden the agent log-follow — mechanism not fully pinned (the agent has no pprof endpoint, so no clean goroutine dump was possible). **Do NOT recreate season10 to fix this** (old-image constraint, §10).
2. **The "setting up" provisioning indicator doesn't surface.** The agent watcher emits `detail="Finishing cluster setup…"` and the dashboard renders a matching amber "setting up" pill — but the agent's own **state-reconciliation loop emits `detail="announced:running"` a beat later and overwrites it**, so the card flips green prematurely. The **Console line** (`auto-provision: aligning the new world's decorations to the cluster…`) *does* show and survives a refresh — that part works. Proper fix: a **dedicated provisioning state field** the reconciler respects, not the shared `detail` string. (The card-pill matcher is live but dormant in `new.html`/`index.html`; harmless.)
3. **Auto-provision watcher probe-path caveat (zero-touch isn't universal yet).** The watcher gates "gen finished" on `main.ttw` **only**; it reads `decoration.7dt` solely to detect the already-canon (≥24000) case. A *missing* `decoration.7dt` still yields `NEEDS_ALIGN` and a bounce — the graceful-stop save writes the native deco, which the entrypoint then remaps on the align boot. For some RWG worlds (e.g. Tuxeno on `cluster-seed-test`/pvp) that two-step doesn't always *complete* zero-touch, but the **entrypoint backstop still aligns the world on its next ordinary restart**, so it ends up correct. Worlds that bake deco at the save path at gen (e.g. refuge-mall) provision fully zero-touch. **Treat the entrypoint remap as the reliable path; the watcher bounce is the optimization.**
4. **`No ItemClass entry for type 678xx` on season10** (root of #1). A set of high-band item ids (mod / RefugeBot runtime items) the merged config references but doesn't define. Tracing the missing items cleans up the console stall, the 13 MB log bloat, and the on-server exceptions in one shot. Not urgent — season10 runs fine (20 FPS, players on).
---
## 13. Handoff status — what's deployed, what's uncommitted
**Cluster:** `cl_37e3a7f72cdc`. Shared `Player/` ≈ 88 `.ttp`. Live members:
| Server | Instance id | Container / saves volume | Active world (`CLUSTER_PLAYER_SAVE`) | Role |
|---|---|---|---|---|
| Season 10 | `rg-season-10` | `panel-rg-season-10` / `panel-rg-season-10-saves` | `West Apeeni Mountains/MyGame` *(canonical anchor)* | **master / anchor — DO NOT Rebuild** |
| Creative | `creative` | `panel-creative` / `panel-creative-saves` | `Tefasizi County/Refuge` | creative |
| Insane Survival | `insane-survival` | `panel-insane-survival` / `panel-insane-survival-saves` | `Yixacove Valley/Refuge` | survival |
| PvP | `cluster-seed-test` | `panel-cluster-seed-test` / `panel-cluster-seed-test-saves` | `Tuxeno Territory/RefugeTHREEPVP` | pvp |
| Mall | `refuge-mall` | `panel-refuge-mall` / `panel-refuge-mall-saves` | `New Vebape Territory/RefugeMall` | mall |
> **Canonical anchor — the single highest-blast-radius fact.** The complete frozen table (**38,626 blocks / 2,719 items**) lives ONLY at season10's `…/Saves/West Apeeni Mountains/MyGame/{block,item}mappings.nim`. season10's *other* save dirs are stale partials (e.g. `Navezgane/MyGame` = 3,356 blocks, `Iupu Territory/Troutdale` = 5,028) and its `CLUSTER_PLAYER_SAVE` env is **empty** (old image, predates the picker — so don't trust the env to find the anchor). Any re-anchor (§6 / §7) MUST copy from exactly `West Apeeni Mountains/MyGame` and **verify the counts read 38,626 / 2,719 (or higher — never lower)** before running `rebuild.py`. Anchoring against a partial silently wipes the ~88 shared characters.
**Deployed (live):**
* **Controller** on kaiten — has the create-form cluster note + the auto-provision "starting" chip + the (dormant) "setting up" card-pill code.
* **Agent** on figaro — has the auto-provision watcher **and** the hardened `ExecCapture` (§11).
* **Image `panel-7dtd:latest`** on figaro — has the entrypoint remap block + `python3-minimal` + the vendored `remap_7dt.py`. Running on creative / insane-survival / cluster-seed-test / refuge-mall.
* **`rg-season-10` deliberately on the OLD image** (no `.panel-save-base`, no remap block) — the live anchor; do not move it.
* **refuge-mall** — created via the panel this session and registered as a RefugeBot tenant (prod DB row Id=31 + `refugebot.json` in its saves volume), connected.
**⚠️ UNCOMMITTED — nothing from this work is in git.** `C:\Users\dbled\sources\panel` is a git working tree but all of the below is uncommitted: the controller dashboard edits, the agent Go (`decoremap_provision.go`, the `dispatch.go` hook + `provisionGuard`, the `ExecCapture` hardening), the module (`entrypoint.sh` remap block, `Dockerfile`, `nim-freeze/remap_7dt.vendored.py`), and these docs. **Rollback points** if a deploy goes bad: `controller.bak-<ts>` on kaiten, `agent.bak-<ts>` on figaro (newest = the binary just before the current one).
**If/when you commit,** suggested grouping: (1) canonical + deco freeze/remap, (2) auto-provision (entrypoint + watcher), (3) the `ExecCapture` hardening, (4) docs. Note in the message that the cluster is **live** so a reviewer treats it as load-bearing.
---
## 14. Disaster recovery — the three irreplaceable trees
These three live on figaro and **cannot be regenerated from the repo** — they *are* the source of truth. If figaro's disk dies, this is what hurts:
| Tree | Path on figaro | What it is | If lost |
|---|---|---|---|
| **The canonical** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/canonical/{block,item}mappings.nim` | the frozen stamp source every member uses | rebuildable from season10's `West Apeeni Mountains/MyGame` anchor via `rebuild.py` (needs `~/nimcanon`) |
| **Shared characters** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/Player/` (= `/cluster/Player`) | the ~88 player `.ttp` (every character + inventory) | **unrecoverable** — everyone resets to level 1 |
| **The rebuild toolchain** | `~/nimcanon/` (`tool/`, `registry/`, `canonical_final/`, `remap/`) | the only parser + name registry + capture tooling to rebuild any of the above | must re-harvest from scratch (the §6 harvest — currently uncommitted) |
> ⚠️ **Open risk — confirm these are actually backed up off-figaro.** `~/nimcanon` is persisted out of `/tmp` (survives a reboot) but a disk loss is unaddressed in these docs. **Recommendation:** snapshot all three trees to another host on a schedule (reuse the panel's backup system or a periodic `tar`). Restore order: canonical → Player → validate with an in-game transfer (CANONICAL_NIM_FREEZE "Verify a server is frozen"). Treat this as the **top open risk** until a backup is confirmed.
+71
View File
@@ -0,0 +1,71 @@
# panel-native 7 Days to Die runtime image.
#
# Unlike third-party images (vinanrra/LGSM, didstopia, etc.), this image does
# ONE thing: run a 7DTD dedicated server. Download + validate is handled by
# the panel's SteamCMD updater sidecar, which writes into the same Docker
# volume we mount read/write here. No LGSM, no tmux, no GitHub-fetch on
# start, no backup-before-start "surprises" — just the game.
#
# Expected runtime layout inside the container:
# /game — SteamCMD-installed 7DTD binaries (panel volume)
# /game-saves — save + config data (panel volume)
# /entrypoint.sh — this image's entrypoint (renders serverconfig.xml and
# execs the game with stdout/stderr unbuffered)
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
# Runtime deps 7DTD's Linux build needs. lib32* for the 32-bit Steam bits,
# libxi/libxrandr for the server's Unity runtime probes, ca-certs so any
# outbound HTTPS (analytics, steam auth) resolves.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
locales \
tini \
lib32gcc-s1 \
lib32stdc++6 \
libc6:i386 \
python3-minimal \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Unprivileged user that owns /game. The updater sidecar writes as root, so
# at start time we chown once to panel (cheap since SteamCMD leaves a known
# set of files). This keeps the long-running game process non-root.
RUN groupadd -g 1000 panel \
&& useradd -u 1000 -g 1000 -m -s /bin/bash panel \
&& mkdir -p /game /game-saves \
&& chown -R panel:panel /game /game-saves
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Vendored nim-tools: the in-container decoration remapper used by the cluster
# auto-provision step in entrypoint.sh. Self-contained (nimtool.read_nim inlined),
# python3-minimal (above) is its only dependency. A new clustered world's native
# decorations are remapped to the cluster canonical here so it loads clean.
COPY nim-freeze/remap_7dt.vendored.py /opt/panel-nimtools/remap_7dt.py
# Panel-owned helper mod — exposes blood-moon status as a JSON file the
# scheduler reads to defer restarts through hordes. Lives in /opt so the
# entrypoint can copy it into /game/Mods/ on every boot (so a steam
# validate or operator overwrite re-seeds it).
COPY mod-source/PanelBloodMoonAgent/bin/Release/net48/PanelBloodMoonAgent.dll /opt/panel-mods/PanelBloodMoonAgent/PanelBloodMoonAgent.dll
COPY mod-source/PanelBloodMoonAgent/ModInfo.xml /opt/panel-mods/PanelBloodMoonAgent/ModInfo.xml
# Game UDP ports — published by the panel manifest at run time.
EXPOSE 26900/udp 26901/udp 26902/udp
# Telnet RCON
EXPOSE 8081/tcp
# tini reaps zombies + forwards signals cleanly so our SIGTERM → graceful stop
# actually lands on the 7DTD process (otherwise docker stop becomes kill -9).
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+22
View File
@@ -0,0 +1,22 @@
# 7 Days to Die module
Steam app id `294420`. Dedicated server runs on Windows and Linux. Configuration is a single `serverconfig.xml` with `<property name="X" value="Y"/>` entries — the panel's schema loader needs an XML-format adapter (not `.properties`).
## RCON surface
7DTD does not speak Source RCON. The admin interface is **telnet on port 8081**, line-oriented, with a password prompt on connect. The module sets `rcon.adapter: telnet`. Commands are plain strings (`lp`, `say "..."`, `kick`, `shutdown`).
## Log patterns
Join and leave patterns use single-quoted fields — `PlayerName='Bob'`. These are stable across A19A22. The panel's log tailer uses named regex groups; extracted fields map into `PlayerEvent.player_id`, `player_name`, etc.
## Ports
- `26900/udp` — game + Steam query
- `26901/udp`, `26902/udp` — peer-to-peer data
- `8080/tcp` — optional web admin
- `8081/tcp` — telnet admin (internal — should NOT be exposed outside the Target)
## Updates
Via SteamCMD, `app_update 294420`. Two branches are surfaced: `stable` and `latest_experimental` (the latter via SteamCMD `beta: latest_experimental`) — see `module.yaml` `update_providers`. The telnet password is auto-generated on first install **only if `TelnetPassword` is empty**, and stored plaintext at `/game-saves/.panel-telnet-password` (also surfaced in the Config tab). Read it there to telnet/RCON in manually during an incident.
+341
View File
@@ -0,0 +1,341 @@
#!/bin/bash
# panel-native 7DTD entrypoint.
#
# Contract:
# /game — SteamCMD-installed dedicated server (volume, populated by
# the panel's SteamCMD updater sidecar). If missing, exit
# with a helpful message telling the operator to run Update.
# /game-saves — save + config writable volume.
# $TELNET_PASSWORD — injected by the panel via a module secret; rendered
# into serverconfig.xml so telnet RCON matches.
#
# On every start we re-render serverconfig.xml from the bind-mounted
# template so operator edits to module values take effect without needing a
# Docker image rebuild. Saves and world data live in /game-saves so a steam
# validate won't ever touch them.
set -euo pipefail
log() { printf '[panel-7dtd] %s\n' "$*"; }
GAME_DIR=/game
SAVE_DIR=/game-saves
CONFIG=${SAVE_DIR}/serverconfig.xml
TEMPLATE=${SAVE_DIR}/serverconfig.xml.rendered
# Allocator-derived ports. Panel sets these via container env (manifest's
# ports[].env: + runtime.docker.env: declarations). Defaults mirror the
# canonical 7DTD numbers so single-instance setups still work without
# the panel allocator.
GAME_PORT="${GAME_PORT:-26900}"
TELNET_PORT="${TELNET_PORT:-8081}"
WEBDASH_PORT="${WEBDASH_PORT:-8080}"
# --- sanity: game files installed? ---
if [[ ! -x "${GAME_DIR}/startserver.sh" && ! -x "${GAME_DIR}/7DaysToDieServer.x86_64" ]]; then
log "ERROR: /game is empty."
log " Game files haven't been downloaded yet."
log " Run 'Update' on this server in the panel — that kicks off a"
log " SteamCMD sidecar which installs 7DTD into this volume."
exit 78 # EX_CONFIG
fi
# --- render serverconfig.xml ---
# The panel writes a pre-rendered serverconfig.xml.rendered into the save
# volume at create/update time (see module create hook). We symlink it into
# the game dir so the game binary finds it on the expected path.
if [[ -s "${TEMPLATE}" ]]; then
log "using panel-rendered config (${TEMPLATE})"
cp -f "${TEMPLATE}" "${CONFIG}"
elif [[ ! -s "${CONFIG}" ]]; then
log "no panel config found — using the shipped default in ${GAME_DIR}/serverconfig.xml"
if [[ -s "${GAME_DIR}/serverconfig.xml" ]]; then
cp -f "${GAME_DIR}/serverconfig.xml" "${CONFIG}"
else
log "ERROR: no default serverconfig.xml shipped with the game either — aborting"
exit 78
fi
fi
# Make sure the game reads OUR config (symlink from game dir → save dir).
ln -sf "${CONFIG}" "${GAME_DIR}/serverconfig.xml"
# --- auto-populate TelnetPassword if empty ---
#
# 7DTD is surprising here: if TelnetPassword="", the server *enables* telnet
# but binds it to the container's loopback only, so any host-mapped port is
# unreachable. The panel's RCON cannot connect → every "list players" call
# fails with `telnet auth: read password prompt: EOF`.
#
# Fix: on first boot, if TelnetPassword is empty we stamp a random one into
# the config + persist the plaintext to /game-saves/.panel-telnet-password
# so the Config tab can surface it to the operator. Once set, we never
# rotate it — operator owns it from then on.
PASS_FILE="${SAVE_DIR}/.panel-telnet-password"
if grep -Eq '<property name="TelnetPassword"[[:space:]]+value=""[[:space:]]*/>' "${CONFIG}" 2>/dev/null; then
if [[ -s "${PASS_FILE}" ]]; then
NEW_PW=$(cat "${PASS_FILE}")
log "using persisted TelnetPassword from ${PASS_FILE}"
else
NEW_PW=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
printf '%s' "${NEW_PW}" > "${PASS_FILE}"
chmod 600 "${PASS_FILE}" || true
log "generated random TelnetPassword (persisted to ${PASS_FILE})"
fi
# Replace the empty-password line in-place. Using | as sed delimiter so
# the replacement can contain / without escaping.
sed -i "s|<property name=\"TelnetPassword\"[[:space:]]*value=\"\"[[:space:]]*/>|<property name=\"TelnetPassword\" value=\"${NEW_PW}\"/>|" "${CONFIG}"
fi
# --- patch port properties from env on every boot ---
# Multi-instance safety: the panel allocator picks unique host ports per
# instance, the agent injects them as env vars, and we patch them into
# serverconfig.xml here so 7DTD actually binds them. Without this, two
# 7DTD instances on the same agent would both default to 26900/8081/8080
# and only one would launch successfully.
#
# 7DTD computes the LiteNetLib alt + p2p backup ports as ServerPort+1 and
# ServerPort+2 internally — only ServerPort needs to be patched. We DO
# allocate the +1/+2 slots in module.yaml so the panel knows they're taken.
log "patching ports: ServerPort=${GAME_PORT}, TelnetPort=${TELNET_PORT}, WebDashboardPort/ControlPanelPort=${WEBDASH_PORT}"
# NOTE: 7DTD 1.0 renamed the web port property "ControlPanelPort" → "WebDashboardPort".
# Both are patched so this works across versions; the legacy line is a harmless
# no-op on 1.0 configs (no ControlPanelPort property to match). Without the
# WebDashboardPort line every instance kept the template default 8080 and, on
# this host-network agent, all but the first to boot threw the Allocs WebServer
# "Address already in use" exception.
sed -i -E \
-e "s|(<property[[:space:]]+name=\"ServerPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${GAME_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"TelnetPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${TELNET_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"ControlPanelPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
-e "s|(<property[[:space:]]+name=\"WebDashboardPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
"${CONFIG}"
# --- point savegame / userdata into the save volume ---
# 7DTD writes saves + admin files under $HOME/.local/share/7DaysToDie by
# default. We redirect that whole tree into /game-saves so steam validate
# runs against /game can't stomp on world data.
mkdir -p "${SAVE_DIR}/.local/share/7DaysToDie"
export HOME=${SAVE_DIR}
# Surface the user data tree as /game/User so the file browser shows it
# alongside Data/, Mods/, steamapps/ — AMP-style flat root layout. The
# agent's lister uses `ls -lAL` which dereferences symlinks, so this
# renders as a real directory in the Files tab. Guard: only create if
# nothing's there OR what's there is already a symlink we can replace —
# never blow away a real directory an operator might have created.
if [[ ! -e "${GAME_DIR}/User" ]] || [[ -L "${GAME_DIR}/User" ]]; then
ln -sfn "${SAVE_DIR}/.local/share/7DaysToDie" "${GAME_DIR}/User"
else
log "WARN: ${GAME_DIR}/User exists as a real directory, leaving it alone"
fi
# --- cluster: share the Player save folder across cluster members ---
# When this instance is in a cluster, CLUSTER_ID is set and /cluster is a
# shared host bind mount (panel mount-override). We symlink this world's
# Player/ subfolder to /cluster/Player so every member reads/writes the same
# player .ttp files (shared progression). World data stays per-server.
if [[ -n "${CLUSTER_ID:-}" ]]; then
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
SAVES_ROOT="${SAVE_DIR}/.local/share/7DaysToDie/Saves"
# WHICH world's Player folder gets shared into /cluster/Player is chosen
# EXPLICITLY by the operator in the panel's Cluster tab ("Shared player
# world" picker). 7DTD names the save folder after the WORLD, and for
# random-gen worlds that name is seed-derived (e.g. "Tefasizi County", not
# the literal "RWG") and changes whenever a new map is generated — so a
# fixed GameWorld/GameName path silently orphans the symlink. The picker
# writes CLUSTER_PLAYER_SAVE = "<World>/<GameName>" and we use it verbatim.
# Until the operator picks one we fall back to the literal
# Saves/<GameWorld>/<GameName> (correct when GameWorld is already a concrete
# world name; an RWG server will just need a one-time pick after first boot).
if [[ -n "${CLUSTER_PLAYER_SAVE:-}" ]]; then
SAVE_BASE="${SAVES_ROOT}/${CLUSTER_PLAYER_SAVE}"
log "cluster ${CLUSTER_ID}: shared player world selected by operator -> ${CLUSTER_PLAYER_SAVE}"
elif [[ -n "${GW}" && -n "${GN}" ]]; then
SAVE_BASE="${SAVES_ROOT}/${GW}/${GN}"
log "cluster ${CLUSTER_ID}: shared player world = ${GW}/${GN} (literal; pick one in the Cluster tab if this server uses RWG)"
else
SAVE_BASE=""
log "WARN: CLUSTER_ID set but no CLUSTER_PLAYER_SAVE and GameWorld/GameName unparseable — skipping Player symlink"
fi
if [[ -n "${SAVE_BASE}" ]]; then
PLAYER_DIR="${SAVE_BASE}/Player"
mkdir -p "${SAVE_BASE}" /cluster/Player
# Record the resolved active-world save dir so the agent's auto-provision
# watcher can locate this world's decoration.7dt (to detect a fresh
# native-banded world that needs aligning to the cluster canonical).
printf '%s' "${SAVE_BASE}" > "${SAVE_DIR}/.panel-save-base" 2>/dev/null || true
# One-time migration: when this server has a real (non-symlink) Player
# dir from standalone play, seed the shared cluster dir from it so this
# server's existing progression becomes the cluster source of truth.
# Hardened for irreplaceable saves (e.g. Season 10):
# * only seed when the shared dir is still EMPTY (first member wins —
# never copy into an already-seeded cluster),
# * snapshot the local Player dir before touching it,
# * only delete the local copy once the seeding copy has SUCCEEDED, so
# a partial copy can never destroy the server's only Player files.
do_symlink=1
if [[ -d "${PLAYER_DIR}" && ! -L "${PLAYER_DIR}" ]]; then
if [[ -z "$(ls -A /cluster/Player 2>/dev/null)" ]]; then
snap="${SAVE_BASE}/Player.preseed-${CLUSTER_ID}"
log "cluster ${CLUSTER_ID}: seeding empty shared Player dir from local saves (snapshot -> ${snap})"
cp -a "${PLAYER_DIR}" "${snap}" 2>/dev/null || log "WARN: pre-seed snapshot failed (continuing)"
if cp -a "${PLAYER_DIR}/." /cluster/Player/ 2>/dev/null; then
rm -rf "${PLAYER_DIR}"
else
log "ERROR: failed to seed /cluster/Player from ${PLAYER_DIR}; KEEPING local Player, NOT symlinking (no data lost)"
do_symlink=0
fi
else
archive="${SAVE_BASE}/Player.local-pre-cluster"
log "cluster ${CLUSTER_ID}: shared Player dir already populated; archiving local Player -> ${archive}, adopting shared progression"
rm -rf "${archive}" 2>/dev/null || true
mv "${PLAYER_DIR}" "${archive}" 2>/dev/null || rm -rf "${PLAYER_DIR}"
fi
fi
if [[ "${do_symlink}" == "1" ]]; then
ln -sfn /cluster/Player "${PLAYER_DIR}"
chown -R 1000:1000 /cluster/Player 2>/dev/null || true
log "cluster ${CLUSTER_ID}: ${PLAYER_DIR} -> /cluster/Player"
fi
# --- auto-provision: align a NEW world's native decorations to canonical ---
# A freshly-generated cluster world bakes decoration.7dt / multiblocks.7dt by
# NUMERIC block id using the engine's NATIVE id table at generation. The stamp
# guard below then puts the CANONICAL .nim over the world's .nim, so those
# native-baked deco ids resolve to a null Block -> DecoManager NRE -> the world
# loads but never reaches GameStartDone ("online but unjoinable"). So BEFORE
# stamping, if this world's decorations are still in the NATIVE id band, remap
# them name-by-name to canonical using THIS WORLD'S OWN native blockmappings.nim
# (which the engine wrote at generation and is still on disk this boot, before
# the stamp). Tree-deco band: native < 24000 / canonical >= 24000 (verified).
# STRICT no-op for already-canon worlds (season10/creative/insane/pvp -> deco in
# canon band, never enters the branch), non-cluster servers (outside CLUSTER_ID),
# and steady state (post-remap deco is canon-band). multiblocks.7dt uses the same
# full native table (NOT a deco subset). See nim-freeze/REMAP_RUNBOOK.md.
DECO_F="${SAVE_BASE}/decoration.7dt"
CANON_B="/cluster/canonical/blockmappings.nim"
NAT_NIM="${SAVE_BASE}/blockmappings.nim"
REMAP_TOOL="/opt/panel-nimtools/remap_7dt.py"
MARK="${SAVE_BASE}/.canon-provisioned"
if [[ -f "${DECO_F}" && -f "${CANON_B}" && -f "${NAT_NIM}" && -f "${REMAP_TOOL}" ]] && command -v python3 >/dev/null 2>&1; then
# first decoration block id = u16 @ file offset 17 (hdr 5 + record id-offset 12); mask to 15 bits.
first_raw="$(od -An -tu2 -j17 -N2 "${DECO_F}" 2>/dev/null | tr -d ' ' || true)"
[[ "${first_raw}" =~ ^[0-9]+$ ]] || first_raw=0
first_deco=$(( first_raw & 0x7FFF ))
if [[ "${first_deco}" -gt 0 && "${first_deco}" -lt 24000 ]]; then
# Native-band deco present. Safety: confirm the on-disk .nim is the world's
# NATIVE table (count != canonical count), so we remap against the right map
# rather than a previously-stamped canonical (which would silently morph).
nat_cnt="$(od -An -tu4 -j4 -N4 "${NAT_NIM}" 2>/dev/null | tr -d ' ' || true)"; [[ "${nat_cnt}" =~ ^[0-9]+$ ]] || nat_cnt=0
canon_cnt="$(od -An -tu4 -j4 -N4 "${CANON_B}" 2>/dev/null | tr -d ' ' || true)"; [[ "${canon_cnt}" =~ ^[0-9]+$ ]] || canon_cnt=0
if [[ "${nat_cnt}" -gt 0 && "${nat_cnt}" != "${canon_cnt}" ]]; then
log "cluster ${CLUSTER_ID}: new world '${SAVE_BASE##*/Saves/}' has native-band decorations (first id ${first_deco}); aligning decorations to cluster (remap native->canonical)…"
remap_ok=1
for f in "${DECO_F}" "${SAVE_BASE}/multiblocks.7dt"; do
[[ -f "${f}" ]] || continue
if python3 "${REMAP_TOOL}" apply "${NAT_NIM}" "${CANON_B}" "${f}" "${f}.canon-tmp" > "/tmp/decoremap.$$" 2>&1; then
cp -f "${f}" "${f}.native-bak"
mv -f "${f}.canon-tmp" "${f}"
chown 1000:1000 "${f}" 2>/dev/null || true
log "cluster ${CLUSTER_ID}: remapped $(basename "${f}") native->canonical ($(grep -oE 'records_rewritten=[0-9]+' "/tmp/decoremap.$$" || true))"
else
log "ERROR: cluster ${CLUSTER_ID}: remap of $(basename "${f}") FAILED — refusing to start (would be unjoinable). remap output:"
while IFS= read -r _l; do log " ${_l}"; done < "/tmp/decoremap.$$"
rm -f "${f}.canon-tmp" "/tmp/decoremap.$$"
remap_ok=0
break
fi
done
rm -f "/tmp/decoremap.$$" 2>/dev/null || true
if [[ "${remap_ok}" == "1" ]]; then
sha256sum "${CANON_B}" 2>/dev/null | cut -d' ' -f1 > "${MARK}" || true
chown 1000:1000 "${MARK}" 2>/dev/null || true
log "cluster ${CLUSTER_ID}: decorations aligned to cluster canonical — world is now canon-compatible"
else
exit 1
fi
else
log "WARN: cluster ${CLUSTER_ID}: native-band decorations but world .nim count=${nat_cnt} == canonical (${canon_cnt}); cannot identify the native table to remap against — leaving as-is (may NRE; see nim-freeze/REMAP_RUNBOOK.md to fix manually)"
fi
fi
fi
# --- cluster canonical .nim freeze guard ---
# Stamp the cluster's canonical block/item id maps over THIS world's .nim
# on every boot, so every member resolves the shared player inventory
# through the SAME id table. This eliminates the per-world id divergence
# that otherwise morphs inventory items (glass block -> car hood) and wipes
# characters to level 1 on transfer: 7DTD stores inventory by NUMERIC id,
# each world bakes its own name<->id table (itemmappings.nim/blockmappings.nim),
# and a shared .ttp decoded through a different table resolves every id to a
# different name. The canonical is the season10-anchored, registry-COMPLETE
# table (every block incl. the full base:shape cross-product, every item +
# every item_modifier), so the engine never has to append a fresh id -> the
# table stays frozen. It lives ONCE in the shared cluster dir
# (/cluster/canonical) and is applied only when the world save dir already
# exists (a freshly-generated world is frozen on its FIRST reboot).
# Idempotent + self-healing: re-stamped every boot, so a recreate/regen or
# any stray append can never silently re-diverge the cluster.
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" || log "WARN: canonical item-id-map stamp failed"
cp -f /cluster/canonical/blockmappings.nim "${SAVE_BASE}/blockmappings.nim" || log "WARN: canonical block-id-map stamp failed"
log "cluster ${CLUSTER_ID}: stamped canonical id maps over ${SAVE_BASE} (frozen, divergence-proof)"
fi
fi
else
# Not clustered: if a stale symlink to /cluster lingers from a prior
# membership, drop it so 7DTD recreates a local Player dir.
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
if [[ -n "${GW}" && -n "${GN}" ]]; then
PLAYER_DIR="${SAVE_DIR}/.local/share/7DaysToDie/Saves/${GW}/${GN}/Player"
if [[ -L "${PLAYER_DIR}" ]]; then rm -f "${PLAYER_DIR}"; fi
fi
fi
# --- sync panel-owned mods into /game/Mods/ ---
# /opt/panel-mods/ is baked into the image. We rsync (well, cp -a) each
# subdir into /game/Mods/ on every boot so:
# - a fresh SteamCMD install picks up our mods automatically
# - operator-deleted mods are restored
# - image rebuilds (with newer mod DLLs) propagate without manual work
# We DO NOT remove other operator-installed mods — only overwrite
# panel-prefixed ones (named "Panel*").
if [[ -d /opt/panel-mods ]]; then
mkdir -p "${GAME_DIR}/Mods"
for src in /opt/panel-mods/*/; do
name=$(basename "${src}")
dest="${GAME_DIR}/Mods/${name}"
log "panel mod sync: ${name}${dest}"
rm -rf "${dest}"
cp -a "${src}" "${dest}"
done
fi
# --- go ---
cd "${GAME_DIR}"
log "starting 7 Days to Die dedicated server"
log " game dir: ${GAME_DIR}"
log " save dir: ${SAVE_DIR}"
log " config: ${CONFIG}"
log " game port: ${GAME_PORT}/udp (+1, +2 implicit)"
log " telnet port: ${TELNET_PORT}/tcp"
log " control port: ${WEBDASH_PORT}/tcp"
# IMPORTANT: we deliberately do NOT use ./startserver.sh — the shipped
# script hardcodes `-logfile <file>`, routing all server output to a
# filesystem log that the panel can't tail without extra bind-mount
# gymnastics. Invoking the binary directly with `-logfile -` streams
# every line to stdout, which docker logs picks up, which the panel
# Console tab can surface in real time.
#
# LD_LIBRARY_PATH is what startserver.sh set — the game loads local .so
# files, not system ones, so this is mandatory.
export LD_LIBRARY_PATH=.
exec stdbuf -oL -eL ./7DaysToDieServer.x86_64 \
-configfile=serverconfig.xml \
-logfile - \
-quit -batchmode -nographics -dedicated
@@ -0,0 +1,436 @@
// PanelBloodMoonAgent — writes a tiny status JSON the panel scheduler
// reads to decide whether to delay a restart through a blood-moon horde.
//
// Status file path: /game-saves/.panel-bloodmoon-status.json
// (lives next to .panel-telnet-password — same volume the entrypoint
// uses for panel-owned state)
//
// Shape:
// {
// "schema": 1,
// "written_at_unix": 1763500000,
// "in_game_day": 14,
// "in_game_hour": 22,
// "in_game_minute": 3,
// "bloodmoon_freq": 7,
// "bloodmoon_range": 0,
// "day_night_length": 60,
// "next_horde_day": 14,
// "active": true,
// "ends_at_unix": 1763500900, // best-effort wall-clock end (0 = unknown)
// "source": "ai_director" // or "math_fallback"
// }
//
// We bind IModApi + Mod at compile time so the 7DTD loader finds our entry
// point through its normal reflection probe. EVERY other game type
// (GamePrefs, World, AIDirector, AIDirectorBloodMoonComponent, GameStats,
// GameUtils) goes through string-keyed reflection — the refugebotserver
// Agent's 2026-04-29 outage taught us that a hard reference to a missing
// EnumGamePrefs member makes Mono refuse to load the whole assembly.
// Type-name lookup keeps us version-tolerant.
using System;
using System.IO;
using System.Reflection;
using System.Threading;
namespace Panel.BloodMoonAgent
{
// IModApi is in Assembly-CSharp. The 7DTD ModManager scans loaded mod
// assemblies for public, non-abstract types implementing this interface
// and instantiates the first one it finds, then calls InitMod(Mod).
public class BloodMoonAgent : IModApi
{
public void InitMod(Mod _modInstance)
{
try
{
if (_instance != null) return; // idempotent — Mod manager only calls once, but be safe
_instance = this;
Start();
TryLog("InitMod completed; status file: " + OutputPath);
}
catch (Exception ex)
{
TryLog("InitMod failed: " + ex.Message);
}
}
private static BloodMoonAgent? _instance;
// Reflection handles — looked up once, cached. Null means "not in
// this game build" and we silently skip that data source.
private Type? _enumPrefsT;
private Type? _gamePrefsT;
private Type? _gameManagerT;
private Type? _gameUtilsT;
private Type? _gameStatsT;
private Type? _enumGameStatsT;
private Thread? _worker;
private volatile bool _stopRequested;
private const string OutputPath = "/game-saves/.panel-bloodmoon-status.json";
private const string OutputPathTmp = "/game-saves/.panel-bloodmoon-status.json.tmp";
private const int TickSeconds = 5;
private void Start()
{
_enumPrefsT = ResolveType("EnumGamePrefs");
_gamePrefsT = ResolveType("GamePrefs");
_gameManagerT = ResolveType("GameManager");
_gameUtilsT = ResolveType("GameUtils");
_gameStatsT = ResolveType("GameStats");
_enumGameStatsT = ResolveType("EnumGameStats");
_worker = new Thread(WorkerLoop) { IsBackground = true, Name = "PanelBloodMoonAgent" };
_worker.Start();
}
private static Type? ResolveType(string name)
{
try
{
var t = Type.GetType(name + ", Assembly-CSharp");
if (t != null) return t;
t = Type.GetType(name);
if (t != null) return t;
// Fallback: scan loaded assemblies. Some 7DTD builds put types
// in unexpected assemblies after Unity merges.
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var hit = asm.GetType(name);
if (hit != null) return hit;
}
catch { }
}
}
catch { }
return null;
}
private void WorkerLoop()
{
// Wait briefly so GameManager.Instance.World has populated. World
// is null during early boot; writing "active=false" too early is
// fine (panel falls back to "treat unknown as not-bloodmoon" =
// restart proceeds normally) but we'd rather get real data ASAP.
while (!_stopRequested)
{
try
{
var status = Snapshot();
WriteStatus(status);
}
catch (Exception ex)
{
TryLog("tick failed: " + ex.Message);
}
for (int i = 0; i < TickSeconds * 10 && !_stopRequested; i++)
{
Thread.Sleep(100);
}
}
}
// ── Snapshot ───────────────────────────────────────────────────────
private Status Snapshot()
{
var s = new Status
{
Schema = 1,
WrittenAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Source = "math_fallback",
};
// Game prefs (string-keyed, safe on missing members)
s.BloodMoonFreq = PrefInt("BloodMoonFrequency", 7);
s.BloodMoonRange = PrefInt("BloodMoonRange", 0);
s.DayNightLength = PrefInt("DayNightLength", 60);
// World time → day/hour/minute via GameUtils.WorldTimeToDays/Hours
ulong worldTime = 0;
object? world = GetWorld();
if (world != null)
{
worldTime = (ulong)(GetFieldValue(world, "worldTime") ?? 0UL);
}
if (worldTime > 0 && _gameUtilsT != null)
{
s.InGameDay = InvokeStatic<int>(_gameUtilsT, "WorldTimeToDays", worldTime);
s.InGameHour = InvokeStatic<int>(_gameUtilsT, "WorldTimeToHours", worldTime);
s.InGameMinute = (int)((worldTime / 60UL) % 60UL);
}
// Authoritative: ask the AIDirector's BloodMoonComponent.
// Path: GameManager.Instance.World.aiDirector.BloodMoonComponent.BloodMoonActive
// GameStats.GetInt(EnumGameStats.BloodMoonDay) → next horde day
bool gotAuthoritative = false;
try
{
if (world != null)
{
var aiDirector = GetFieldOrProp(world, "aiDirector");
if (aiDirector != null)
{
var bmComponent = GetFieldOrProp(aiDirector, "BloodMoonComponent");
if (bmComponent != null)
{
var activeObj = GetFieldOrProp(bmComponent, "BloodMoonActive");
if (activeObj is bool active)
{
s.Active = active;
gotAuthoritative = true;
s.Source = "ai_director";
}
}
}
}
}
catch (Exception ex)
{
TryLog("authoritative bloodmoon lookup failed: " + ex.Message);
}
// Next horde day via GameStats.GetInt(EnumGameStats.BloodMoonDay)
try
{
if (_gameStatsT != null && _enumGameStatsT != null
&& Enum.IsDefined(_enumGameStatsT, "BloodMoonDay"))
{
var key = Enum.Parse(_enumGameStatsT, "BloodMoonDay");
var m = _gameStatsT.GetMethod("GetInt", new[] { _enumGameStatsT });
if (m != null)
{
var v = m.Invoke(null, new[] { key });
s.NextHordeDay = Convert.ToInt32(v ?? 0);
}
}
}
catch (Exception ex)
{
TryLog("GameStats.GetInt(BloodMoonDay) failed: " + ex.Message);
}
// Math fallback for Active if AIDirector wasn't available.
// Window is in-game 22:00 (bloodmoon day) → 04:00 (next day).
// This is wrong when BloodMoonRange > 0 — the authoritative path
// is the correct answer in that case. We treat math as best-effort.
if (!gotAuthoritative && s.BloodMoonFreq > 0 && s.InGameDay > 0)
{
int day = s.InGameDay;
int freq = s.BloodMoonFreq;
bool todayIsBM = (day % freq == 0);
bool yesterdayWasBM = (day > 1) && ((day - 1) % freq == 0);
if (todayIsBM && s.InGameHour >= 22) s.Active = true;
else if (yesterdayWasBM && s.InGameHour < 4) s.Active = true;
// Also derive NextHordeDay from math if we didn't get it from GameStats
if (s.NextHordeDay == 0)
{
int rem = freq - (day % freq);
s.NextHordeDay = day + ((rem == 0) ? freq : rem);
}
}
// Compute EndsAtUnix when active: time until in-game 04:00 of the
// morning after the bloodmoon day, scaled by DayNightLength.
// DayNightLength = real minutes per in-game 24h day.
if (s.Active && s.DayNightLength > 0 && worldTime > 0)
{
int gameMinutesLeft;
if (s.InGameHour >= 22)
{
// 22:00 → 28:00 (04:00 next day) = up to 6h = 360 game-minutes
gameMinutesLeft = ((28 - s.InGameHour) * 60) - s.InGameMinute;
}
else if (s.InGameHour < 4)
{
gameMinutesLeft = ((4 - s.InGameHour) * 60) - s.InGameMinute;
}
else
{
// Mid-day bloodmoon? Only possible if AIDirector says so
// outside the normal window. Conservative: assume 360 game-min.
gameMinutesLeft = 360;
}
if (gameMinutesLeft < 0) gameMinutesLeft = 0;
// 1440 game-minutes per in-game day → real seconds per game-min:
// realSecPerGameMin = (DayNightLength * 60) / 1440 = DayNightLength / 24
double realSecondsLeft = (double)gameMinutesLeft * s.DayNightLength / 24.0;
s.EndsAtUnix = s.WrittenAtUnix + (long)Math.Ceiling(realSecondsLeft);
}
return s;
}
// ── GamePrefs helpers (mirrors refugebotserver's pattern) ──────────
private int PrefInt(string name, int fallback)
{
try
{
if (_enumPrefsT == null || _gamePrefsT == null) return fallback;
if (!Enum.IsDefined(_enumPrefsT, name)) return fallback;
var key = Enum.Parse(_enumPrefsT, name);
var m = _gamePrefsT.GetMethod("GetInt", new[] { _enumPrefsT });
if (m == null) return fallback;
return Convert.ToInt32(m.Invoke(null, new[] { key }) ?? fallback);
}
catch { return fallback; }
}
// ── Reflection helpers ─────────────────────────────────────────────
private object? GetWorld()
{
try
{
if (_gameManagerT == null) return null;
// GameManager.Instance is a static FIELD (not a property) in
// current 7DTD builds. Try field first, then property as a
// safety net for older/newer builds.
object? instance = null;
var instF = _gameManagerT.GetField("Instance",
BindingFlags.Public | BindingFlags.Static);
if (instF != null) instance = instF.GetValue(null);
if (instance == null)
{
var instP = _gameManagerT.GetProperty("Instance",
BindingFlags.Public | BindingFlags.Static);
if (instP != null) instance = instP.GetValue(null);
}
if (instance == null) return null;
return GetFieldOrProp(instance, "World");
}
catch { return null; }
}
private static object? GetFieldOrProp(object target, string name)
{
if (target == null) return null;
var t = target.GetType();
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (f != null) return f.GetValue(target);
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null) return p.GetValue(target);
return null;
}
private static object? GetFieldValue(object target, string name)
{
return GetFieldOrProp(target, name);
}
private static T InvokeStatic<T>(Type t, string methodName, params object[] args)
{
try
{
var argTypes = new Type[args.Length];
for (int i = 0; i < args.Length; i++) argTypes[i] = args[i].GetType();
var m = t.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static, null, argTypes, null);
if (m == null) return default!;
var v = m.Invoke(null, args);
return v == null ? default! : (T)Convert.ChangeType(v, typeof(T));
}
catch { return default!; }
}
// ── Output ─────────────────────────────────────────────────────────
private void WriteStatus(Status s)
{
string json = s.ToJson();
try
{
// Atomic write: write to .tmp, then rename. Reader sees either
// the old file or the new one, never a torn write.
File.WriteAllText(OutputPathTmp, json);
// File.Replace fails on Mono if destination doesn't exist; use
// a plain Move+Delete fallback.
if (File.Exists(OutputPath))
{
File.Delete(OutputPath);
}
File.Move(OutputPathTmp, OutputPath);
}
catch (Exception ex)
{
TryLog("write status failed: " + ex.Message);
}
}
// ── Logging ────────────────────────────────────────────────────────
// 7DTD's Log.Out is in Assembly-CSharp. We do best-effort reflection
// so logs surface in the game console; fall back to stderr otherwise.
private static Type? _logT;
private static MethodInfo? _logOutM;
private static bool _logResolved;
private static void TryLog(string msg)
{
try
{
if (!_logResolved)
{
_logT = Type.GetType("Log, Assembly-CSharp") ?? Type.GetType("Log");
if (_logT != null)
{
_logOutM = _logT.GetMethod("Out", new[] { typeof(string) });
}
_logResolved = true;
}
var line = "[PanelBloodMoonAgent] " + msg;
if (_logOutM != null)
{
_logOutM.Invoke(null, new object[] { line });
}
else
{
Console.Error.WriteLine(line);
}
}
catch { }
}
// ── Status DTO ─────────────────────────────────────────────────────
// Hand-rolled JSON — keeps the mod dependency-free (no Newtonsoft
// load, no risk of clashing with 7DTD's bundled copy).
private class Status
{
public int Schema;
public long WrittenAtUnix;
public int InGameDay;
public int InGameHour;
public int InGameMinute;
public int BloodMoonFreq;
public int BloodMoonRange;
public int DayNightLength;
public int NextHordeDay;
public bool Active;
public long EndsAtUnix;
public string Source = "";
public string ToJson()
{
return "{"
+ "\"schema\":" + Schema
+ ",\"written_at_unix\":" + WrittenAtUnix
+ ",\"in_game_day\":" + InGameDay
+ ",\"in_game_hour\":" + InGameHour
+ ",\"in_game_minute\":" + InGameMinute
+ ",\"bloodmoon_freq\":" + BloodMoonFreq
+ ",\"bloodmoon_range\":" + BloodMoonRange
+ ",\"day_night_length\":" + DayNightLength
+ ",\"next_horde_day\":" + NextHordeDay
+ ",\"active\":" + (Active ? "true" : "false")
+ ",\"ends_at_unix\":" + EndsAtUnix
+ ",\"source\":\"" + JsonEscape(Source) + "\""
+ "}";
}
private static string JsonEscape(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xml>
<Name value="PanelBloodMoonAgent" />
<DisplayName value="Panel Blood-Moon Agent" />
<Description value="Writes a small JSON status file the panel scheduler reads to delay restarts during blood moons." />
<Author value="panel" />
<Version value="0.1.0" />
<Website value="" />
</xml>
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Panel.BloodMoonAgent</RootNamespace>
<AssemblyName>PanelBloodMoonAgent</AssemblyName>
<OutputType>Library</OutputType>
<!--
We reference Assembly-CSharp.dll ONLY to bind IModApi + Mod at compile
time so the 7DTD loader's reflection probe finds our entry point. All
game DATA access (GamePrefs, World, AIDirector, etc.) goes through
string-keyed reflection (see BloodMoonAgent.cs) to dodge the
EnumGamePrefs landmine where a missing enum member stops Mono from
loading the whole assembly. The two-type binding is stable across 7DTD
versions — IModApi has had the same shape since A19.
Private=false means we don't ship Assembly-CSharp.dll alongside our DLL;
it's resolved at runtime from the game's own Managed folder.
Override LocalAssemblyCSharp on the command line to point at a different
game install:
dotnet build -p:LocalAssemblyCSharp=D:\path\to\Assembly-CSharp.dll
-->
<LocalAssemblyCSharp Condition="'$(LocalAssemblyCSharp)' == ''">C:\7dtdserver\7DaysToDieServer_Data\Managed\Assembly-CSharp.dll</LocalAssemblyCSharp>
<NoWarn>$(NoWarn);CS8632</NoWarn>
</PropertyGroup>
<ItemGroup>
<Reference Include="Assembly-CSharp">
<HintPath>$(LocalAssemblyCSharp)</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
+326
View File
@@ -0,0 +1,326 @@
# 7 Days to Die — panel-native dedicated server module.
#
# Unlike the earlier vinanrra/LGSM-based revision, this module owns the
# whole stack:
#
# 1. A minimal Debian runtime image (see ./Dockerfile) is built locally
# as panel-7dtd:latest. It contains ONLY the libraries the game binary
# needs — no LGSM, no tmux, no GitHub-fetch-on-start, no surprise
# backups. Just the server process.
# 2. Game files are installed + validated into a Docker volume by the
# panel's generic SteamCMD updater (`agent/internal/updater/steamcmd.go`).
# That sidecar uses the official steamcmd/steamcmd:latest image.
# 3. The entrypoint renders our serverconfig.xml template, drops into the
# `panel` user, and execs startserver.sh under tini with line-buffered
# stdout so progress streams live to the panel console.
#
# First-run flow for operators:
# - Create instance in the panel → container spec created, volumes empty
# - Click "Update" on the instance → SteamCMD sidecar downloads ~17 GB
# - Click "Start" → entrypoint launches 7DTD directly
#
# Subsequent starts skip SteamCMD entirely — instant launch.
id: 7dtd
name: "7 Days to Die"
version: 0.4.0
authors:
- panel contributors
supported_modes:
- docker
# Create the instance but DON'T auto-start it. The operator finishes setup
# (world/RWG settings, cluster join + mod-seed, RefugeBot) and starts it
# manually. This also removes the warm-seed-then-immediate-start race that
# could OOM the agent host. The first SteamCMD update / warm-seed still runs on
# create (auto_update_on_create stays default-true); only the START is
# suppressed — enforced by a shouldAutoStart() guard in the agent's
# onUpdateComplete (the warm-seed completion hook), since that path isn't
# otherwise gated by this flag.
auto_start_on_create: false
runtime:
docker:
# Built locally from ./Dockerfile. Rebuild with:
# docker build -t panel-7dtd:latest modules/7dtd
image: panel-7dtd:latest
# The Files tab exposes TWO roots — operators can switch between them
# with a dropdown at the top of the tab:
#
# Saves & Configs (/game-saves) — world data, serverconfig.xml,
# admin files. Default view.
# Game Files (/game) — binaries + Mods/ folder + Data/.
# Where you drop a mod tarball to install it.
#
# `browseable_root` is kept for backward compat (single-root pre-
# feature clients) but `browseable_roots` wins if both are set.
# Host networking — the container shares the host's network namespace,
# so any port the game OR a mod binds is directly on the LAN. No
# `-p host:container` publishing required; mods like RefugeBot can
# port-hunt freely (the AMP model — drop the .dll, mod sniffs free
# port, LAN_IP:<port> just works).
#
# Tradeoff: every 7DTD on this agent shares lo. The panel's port
# allocator + the env-mapped ports below keep distinct instances
# from clobbering each other on the canonical 7DTD ports.
network_mode: host
browseable_root: /game
browseable_roots:
- name: "Game Files"
path: /game
hint: "AMP-style flat layout: Data/, Mods/, steamapps/, User/, serverconfig.xml"
- name: "Saves & Configs"
path: /game-saves
hint: "Underlying save volume — telnet password, rendered template, raw .local tree"
# Pre-declare the port env keys so the agent's resolve.go filter lets
# panel-allocated values flow through to the container env (only keys
# already present in docker.env get overridden by config_values).
# Defaults match the manifest port defaults below.
env:
GAME_PORT: "26900"
TELNET_PORT: "8081"
WEBDASH_PORT: "8080"
REFUGEBOT_PORT: "8082"
# Cluster membership marker. Empty for standalone servers. The
# controller's 7dtd cluster manager sets this (via config_values)
# when the server joins a cluster; the entrypoint reads it to
# symlink this world's Player save folder into /cluster/Player.
# Pre-declared here so resolve.go's filter lets the override flow
# through (only keys already present in docker.env get overridden).
CLUSTER_ID: ""
# Operator-selected world whose Player folder is shared into
# /cluster/Player ("<World>/<GameName>"). Set via the Cluster tab's
# "Shared player world" picker (config_values). Pre-declared here so
# resolve.go's env filter lets it flow through to the container; the
# entrypoint reads it to override the literal GameWorld/GameName symlink
# target — required for RWG worlds whose save folder is named after the
# generated seed (e.g. "North Tuxera Valley"), not the literal "RWG".
CLUSTER_PLAYER_SAVE: ""
volumes:
# Saves, configs, admin files. Never touched by SteamCMD validate.
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
# Game install — populated by SteamCMD sidecar. Differential updates
# on a named volume avoid Windows NTFS bind-mount misery.
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
# Cluster shared dir. Defaults to a private per-instance named volume
# so a standalone server has a harmless empty /cluster. The controller's
# 7dtd cluster manager OVERRIDES this (mount_overrides) to a shared host
# dir when the server joins a cluster, so every member's entrypoint can
# symlink its Player save folder into the same /cluster/Player.
- { type: volume, name: "panel-$INSTANCE_ID-cluster", container: "/cluster" }
# Bind logs to host so operators can tail / rotate.
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
# Bind the panel-rendered serverconfig.xml into the saves volume so
# the entrypoint picks it up. Without this, the entrypoint falls back
# to the stock serverconfig.xml shipped with the game (ServerName=
# "My Game Host" etc.) and the operator's config_values are ignored.
- { target: "$DATA_PATH/serverconfig.xml.rendered", container: "/game-saves/serverconfig.xml.rendered", read_only: true }
ports:
# 7DTD always binds three contiguous UDP ports starting at ServerPort:
# the game port, then game+1 (LiteNetLib alt) and game+2 (peer-to-peer
# backup). We only inject `game` into the container env (entrypoint
# patches ServerPort in serverconfig.xml). gameA/gameB are declared so
# the allocator records them as taken — preventing a sibling instance
# from grabbing game+1 or game+2 — but their env field is intentionally
# blank (the binary computes those from ServerPort, not env).
- { name: game, proto: udp, default: 26900, required: true, env: GAME_PORT }
- { name: gameA, proto: udp, default: 26901, required: true }
- { name: gameB, proto: udp, default: 26902, required: true }
- { name: telnet, proto: tcp, default: 8081, internal: true, env: TELNET_PORT }
# Vanilla 7DTD's built-in web dashboard. The TFP entrypoint binds
# this when WebDashboardEnabled=true in serverconfig.xml.
- { name: webdash, proto: tcp, default: 8080, required: false, env: WEBDASH_PORT }
# RefugeBot mod's web UI (Admin Panel + Player Shop). Not required —
# only listens when RefugeBot is installed via Settings → Bundled
# mods. Declared here so the panel allocates a unique host port for
# it (otherwise multiple 7DTDs would all collide on 8082) and Docker
# publishes the binding so LAN clients can reach LAN_IP:<host_port>.
# If a 7DTD instance doesn't have RefugeBot installed, the port is
# still bound but nothing answers — harmless.
- { name: refugebot, proto: tcp, default: 8082, required: false, env: REFUGEBOT_PORT }
resources:
min_ram_mb: 4096
recommended_ram_mb: 8192
# First-start config: the entrypoint copies /game/serverconfig.xml (the one
# shipped with the game install) into /game-saves on first run.
rcon:
adapter: telnet
host_port: telnet
auth: password_prompt
# Our entrypoint generates a random TelnetPassword on first boot (7DTD
# only listens on 0.0.0.0 when a password is set — otherwise it's
# container-loopback only and the agent can't reach it). The plaintext
# lands here inside the container for the agent to re-read on each RCON
# dial attempt.
password_from_file: /game-saves/.panel-telnet-password
commands:
list_players: "lp"
say: "say \"{msg}\""
kick: "kick {player} \"{reason}\""
ban: "ban add {player} {duration} \"{reason}\""
shutdown: "shutdown"
save: "saveworld"
state_sources:
# "lp" (list players) returns "Total of N in the game".
- type: rcon
command: "lp"
every: 30s
parse:
kind: regex
pattern: 'Total of (?P<players>\d+) in the game'
fields:
players_online: players
# 7DTD writes its real game log to /game/output_log_*.txt when invoked
# via startserver.sh. Our entrypoint bypasses that and streams to stdout
# (so docker logs → agent → panel console is the primary path). We still
# watch the log files as a belt-and-braces source for event parsing.
- type: log_tail
files:
- "$DATA_PATH/logs/*.log"
- "/game/output_log_*.txt"
events:
join:
pattern: "PlayerSpawnedInWorld \\(by .*?\\): PltfmId='(?P<platform_id>[^']+)', CrossId='(?P<cross_id>[^']*)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
kind: join
leave:
pattern: "Player disconnected: EntityID=\\d+, PltfmId='(?P<platform_id>[^']+)', OwnerID='(?P<owner>[^']+)', PlayerName='(?P<name>[^']+)'"
kind: leave
chat:
pattern: "Chat \\(from '.*?', entity id '\\d+', to '.*?'\\): (?:\\[.*?\\])?'(?P<name>[^']+)': (?P<msg>.*)"
kind: chat
death:
pattern: "GMSG: Player '(?P<name>[^']+)' died"
kind: death
# Render serverconfig.xml from templates/serverconfig.xml.tmpl on every
# (re)create, using the merged operator config_values + generated secrets
# as `.Values.*`. The output lands at $DATA_PATH/serverconfig.xml.rendered
# on the agent host and is bind-mounted into /game-saves/ where the
# entrypoint copies it over the live serverconfig.xml.
config_files:
- path: serverconfig.xml.rendered
format: xml
template: templates/serverconfig.xml.tmpl
# V3.0 "Dead Hot Summer" overhauls serverconfig.xml: ~29 gameplay
# properties are removed and folded into one encoded SandboxCode property
# (the 150 in-game Sandbox Options). A 3.0 server therefore needs a
# different template than ≤2.6. Selection is by the instance's normalized
# Steam branch (resolveCreateBranch): the 3.0 experimental branch normalizes
# to "latest_experimental", so it renders serverconfig.v3.xml.tmpl; every
# other branch (v2.6 default, public, historical) keeps the untouched 2.6
# template. When TFP cuts a frozen `v3.0`/`v3.x` branch near stable, add the
# matching normalized key(s) here — no code change needed (RenderForBranch /
# ConfigFile.TemplateForBranch are data-driven). See
# panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
template_by_branch:
latest_experimental: templates/serverconfig.v3.xml.tmpl
# 7DTD ships per-version LOCKED Steam branches on app 294420 alongside the
# moving `public` branch. Branch names + frozen buildids confirmed live via
# `steamcmd +app_info_print 294420` on 2026-06-14:
# public -> buildid 22422094 (MOVING default; becomes 3.0 on 2026-06-29)
# v2.6 -> buildid 22422094 ("Version 2.6 Stable" — frozen; == public today)
# v2.5/v2.4/v2.3/v2.0/v1.4/alpha21.2 ... -> older frozen stables
# (latest_experimental + the 3.0 experimental live under privatebranches)
#
# ORDER MATTERS. UpdateProviders[0] is the default the agent uses for
# warm-seed install-path resolution AND for the create-time auto-update when
# no provider_id is chosen (pickProviderFromConfigValues). We put the FROZEN
# `v2_6` branch FIRST so a server that doesn't explicitly pick a version stays
# locked to 2.6 and can NEVER drift onto the moving public branch when it flips
# to 3.0. Pinning a server to `-beta v2.6` freezes it at buildid 22422094
# permanently — the corruption guard the whole V3.0 readiness work is built on.
#
# Operators pick a version at create via the "Game version" dropdown (which
# writes config_values.provider_id); the chosen id maps to the matching
# provider here. See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md.
update_providers:
- id: v2_6 # DEFAULT — Version 2.6 Stable, frozen (buildid 22422094)
kind: steamcmd
app_id: "294420"
beta: "v2.6"
install_path: /game # goes directly into our /game volume
- id: latest_experimental # rolling experimental — V3.0 "Dead Hot Summer" lands here 2026-06-15
kind: steamcmd
app_id: "294420"
beta: latest_experimental
install_path: /game
- id: current # public/stable — MOVING. == 2.6 today; becomes 3.0 on 2026-06-29.
kind: steamcmd
app_id: "294420"
install_path: /game
- id: stable # back-compat alias for `current` (pre-2026-06-14 rows persisted provider_id=stable)
kind: steamcmd
app_id: "294420"
install_path: /game
# Historical version-locked branches — pin here to run an older client.
- id: v2_5
kind: steamcmd
app_id: "294420"
beta: "v2.5"
install_path: /game
- id: v2_4
kind: steamcmd
app_id: "294420"
beta: "v2.4"
install_path: /game
- id: v2_3
kind: steamcmd
app_id: "294420"
beta: "v2.3"
install_path: /game
- id: v2_0
kind: steamcmd
app_id: "294420"
beta: "v2.0"
install_path: /game
- id: v1_4
kind: steamcmd
app_id: "294420"
beta: "v1.4"
install_path: /game
# Backup scope — capture the whole user folder + server config.
#
# 7DTD writes saves, admin files, and titlestorage into the per-user
# tree at $HOME/.local/share/7DaysToDie/ (our entrypoint pins HOME to
# /game-saves so this becomes /game-saves/.local/share/7DaysToDie/).
# We grab that, the rendered serverconfig.xml, the persisted telnet
# password, and any 7DTD-side config under .config — that's everything
# an operator would lose if the volume vanished.
#
# Excluded paths:
# - logs/ — large, never useful for restore
# - Steam/ — Steam runtime cache, regenerated on next start
backup:
# Saves + admin files live in /game-saves, not /game. The file browser
# defaults to /game so operators can see the binary tree, but the
# backup must target /game-saves where the world data actually lives.
root: /game-saves
include:
- .local/share/7DaysToDie
- .config
- serverconfig.xml
- .panel-telnet-password
exclude:
- logs
- Steam
# --- Manifest-driven UI metadata (WI-07) --------------------------
# Consumed by the CONTROLLER/dashboard only (the controller loads its
# own ./modules copy at startup); the agent ignores these at runtime.
# ready_pattern is a JavaScript regex (slash-delimited when it needs
# flags) moved verbatim from the dashboard's READY_PATTERNS map.
ready_pattern: '/StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i'
appearance:
emoji: "🧟"
grad: "linear-gradient(135deg,#7b3b12,#d97706)"
art: "/game-art/7dtd.jpg"
steam: "251570"
+58
View File
@@ -0,0 +1,58 @@
# 7DTD Cluster — Canonical-Compatible New World Provisioning (RUNBOOK)
# Validated 2026-06-13. Tooling: this dir. Canonical: ~/nimcanon/canonical_final/{block,item}mappings.nim
## WHERE TO RUN (prerequisites — read first)
Run on **figaro** (`<user>@<agent-host>`) as user **refuge**. Requires `~/nimcanon/{tool,canonical_final,remap}` present (the `.nim` parser, the canonical, the cached native table). The repo `remap_7dt.py` hardcodes `sys.path.insert(0,'/home/refuge/nimcanon/tool')` and imports `nimtool` — it will **NOT** run on the Windows dev box or kaiten. (The in-container copy invoked automatically by the entrypoint is the self-contained `remap_7dt.vendored.py` — a different file.)
Saves live in a **docker volume**, not a host path — `$W` below is only reachable by mounting the volume. Run file ops inside a throwaway container, e.g.:
```sh
CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc
VOL=panel-<inst>-saves # e.g. panel-cluster-seed-test-saves
docker run --rm -v $VOL:/sv -v $CL:/cl debian:12-slim sh -c '
W="/sv/.local/share/7DaysToDie/Saves/<World>/<GameName>"; <ops on "$W/decoration.7dt" etc.>'
```
STOP/START **only via the panel** (agent-owned; sends SIGTERM→tini→7DTD clean shutdown, which flushes the native `.nim`). A `docker kill`/`docker stop` would NOT flush and can corrupt the capture.
## CORRECTED ROOT CAUSE
7DTD V2.6(b14) auto-assigns block ids at config-load. The cluster's CURRENT runtime
(RefugeBot v1.2.39) puts the tree-decoration band at native ids 21895+, while the
season10-anchored CANONICAL puts the same trees at 24141+ (season10's world was baked
PRE-RefugeBot-rebuild). At world GENESIS the engine bakes decoration.7dt / multiblocks.7dt
BY NUMERIC BLOCK ID using native runtime ids. The entrypoint guard then stamps the
canonical .nim over the world's .nim every boot (so inventory/.ttp decode uniformly →
clean transfers). MISMATCH: native-baked deco (21895) read against stamped canonical
(24141) → null Block → DecoManager.TryAddToOccupiedMap NRE → world load hangs → never
GameStartDone. season10/creative load clean only because their deco was baked when the
runtime == canonical (pre-rebuild).
## KEY FACTS
- .nim: u32 ver=1, u32 count, recs[u16 id, u16 flag(block=0/item=1), u8 namelen, name]. Parser: ~/nimcanon/tool/nimtool.py
- decoration.7dt / multiblocks.7dt: u8 ver=6, u32 count, count*17B recs; block id = u16@+12 & 0x7FFF; high bit + bytes +14/+16 = rotation/placement (PRESERVE).
- Engine PRESERVES loaded deco ids on save; only writes native ids at GENERATION. So a canon-remapped decoration.7dt stays canon across reboots → STABLE.
- The cluster runtime native table is IDENTICAL across all instances (insane==pvp deco band 33/33). One captured native table is authoritative cluster-wide: ~/nimcanon/remap/cluster_native_deco.nim
- Remapper: remap_7dt.py (apply|analyze|decodecheck). Self-verifies byte-safety + 100% canon resolution.
## PROVISION A NEW CANONICAL-COMPATIBLE WORLD (do this on a FRESH/0-region world)
CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc ; VOL=panel-<inst>-saves ; W="<World>/<GameName>"
1. Gen the world (set seed, recreate/start). It bakes a NATIVE decoration.7dt.
2. CAPTURE the world's native table:
a. Stop the instance (panel Stop — NOT `docker stop`).
b. **First BACK UP the canonical** (`cp $CL/canonical/*.nim ~/canon-backup/`), THEN move the originals aside so the guard skips the stamp:
`mv $CL/canonical/*.nim $CL/canon_test_aside/`
⚠️ **The canonical is the SHARED stamp source for EVERY cluster member.** While it is moved aside, do **NOT** boot any other member — it would stamp nothing and could append/diverge (cluster-wide inventory risk). Keep this window short; restore in step 5 and confirm by `sha256sum` before starting anything else.
c. printf '\x01\x00\x00\x00\x00\x00\x00\x00' > "$W/blockmappings.nim" ; same for itemmappings.nim
d. Start instance → loads clean on native → engine writes the world's native .nim (covers its deco blocks). Graceful-stop to flush.
e. Copy out "$W/blockmappings.nim" as <native.nim>.
3. REMAP: python3 remap_7dt.py apply <native.nim> ~/nimcanon/canonical_final/blockmappings.nim "$W/decoration.7dt" /tmp/deco.canon.7dt
(repeat for multiblocks.7dt if present). Require: "byte-diff outside id field: 0" and "ids unresolved in canon: 0".
4. Backup native (cp decoration.7dt decoration.7dt.native-bak) then apply the remapped file; chown 1000:1000.
5. mv $CL/canon_test_aside/*.nim $CL/canonical/ # restore the stamp source
6. Start instance → guard stamps canon → loads canon-remapped deco → GameStartDone, NRE=0, on canon.
7. VERIFY: decoration.7dt id band = 24xxx ; GameStartDone ; NRE=0 ; .nim stamped 38626/2719 at boot.
## REPAIR AN ALREADY-STUCK WORLD (canon stamped, native deco, NRE) — same as above from step 2.
## CAVEATS
- Region/*.7rg voxel ids + Player/*.ttp item ids are NOT remapped (codecs unsolved). Do the remap on a FRESH 0-region world BEFORE exploration. Existing 87 shared .ttp are fine (written in canon by season10/creative).
- Deco block ids must be < 32768 (15-bit field); the tree-deco band is far under.
- PERMANENT fix (removes the per-world remap step): pin block ids explicitly in a high-priority modlet so the runtime native table == canonical, OR freeze the RefugeBot build that season10 was baked under. Until then each new world needs this one-time remap.
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Rebuild the 7DTD cluster canonical .nim (re-anchor to season10's CURRENT state).
WHAT THIS DOES
Produces a complete, season10-anchored canonical block/item id map. Every id
the anchor (season10) currently uses is preserved BYTE-FOR-BYTE; every other
name from the "universe" (a prior complete canonical) is appended at a fresh,
non-colliding id above the anchor's max. The result is a superset that decodes
season10's world + all shared inventories identically, and is COMPLETE so the
engine never appends => the table stays frozen forever.
WHEN TO RUN
* season10's live .nim grew (players placed new shapes) and you need to
re-stamp it — re-anchor so the canonical preserves the new ids.
* You just want to regenerate the deployed canonical from the anchor.
NOTE: if MODS CHANGED (new blocks/items added), the universe won't contain the
new names. Re-harvest first — see CANONICAL_NIM_FREEZE.md "Rebuilding after a
mod change". This script only RE-ANCHORS an existing complete name set.
USAGE
1. STOP season10 (panel Stop) so its .nim is final.
2. Copy season10's CURRENT blockmappings.nim + itemmappings.nim out (the ANCHOR):
docker run --rm -v panel-rg-season-10-saves:/sv:ro -v /tmp/x:/o debian:12-slim \
sh -c 'cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/blockmappings.nim" /o/anchor_block.nim;
cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/itemmappings.nim" /o/anchor_item.nim'
3. python3 rebuild.py anchor_block.nim anchor_item.nim \
UNIVERSE_block.nim UNIVERSE_item.nim OUT_DIR
(UNIVERSE = a prior complete canonical, e.g. ~/nimcanon/canonical_final/*.nim)
4. Confirm "GATE PASS: True". Then copy OUT_DIR/*.nim into the cluster canonical
dir and chmod 644:
cp OUT_DIR/*.nim /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/
chmod 644 /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/*.nim
5. Rebuild each cluster member (POST /api/instances/<id>/rebuild) — the entrypoint
guard re-stamps the new canonical on boot. (If a member was STOPPED, /rebuild
leaves it stopped — Start it after.)
.nim BINARY FORMAT (little-endian, no trailing bytes):
u32 version(=1), u32 count, then count records of:
u16 id, u16 flag (items=1, blocks=0), u8 namelen, <namelen> UTF-8 name bytes
Records are stored sorted by ascending id. Ids are SPARSE.
"""
import struct, sys, hashlib, os
def read_nim(p):
b = open(p, "rb").read()
ver, cnt = struct.unpack_from("<II", b, 0)
off = 8
recs = []
for _ in range(cnt):
i, fl, nl = struct.unpack_from("<HHB", b, off)
off += 5
nm = b[off:off + nl].decode("utf-8")
off += nl
recs.append((i, fl, nm))
assert off == len(b), f"trailing bytes parsing {p} ({off} != {len(b)})"
return recs
def write_nim(recs, p):
out = struct.pack("<II", 1, len(recs))
for i, fl, nm in recs:
nb = nm.encode("utf-8")
out += struct.pack("<HHB", i, fl, len(nb)) + nb
open(p, "wb").write(out)
return out
def build(anchor, universe, flag):
"""anchor ids are SACRED (verbatim). append every universe name not present."""
recs = list(anchor)
present = {nm for _, _, nm in anchor}
used = {i for i, _, _ in anchor}
nid = max(used) + 1
for nm in sorted({nm for _, _, nm in universe} - present):
while nid in used:
nid += 1
if nid >= 65536:
raise SystemExit("FATAL: ran out of u16 id space (>65535)")
recs.append((nid, flag, nm))
used.add(nid)
nid += 1
recs.sort(key=lambda r: r[0])
return recs
def main():
if len(sys.argv) != 6:
print(__doc__)
sys.exit(2)
ab, ai, ub, ui, outdir = sys.argv[1:6]
os.makedirs(outdir, exist_ok=True)
anchor_b, anchor_i = read_nim(ab), read_nim(ai)
univ_b, univ_i = read_nim(ub), read_nim(ui)
fb, fi = build(anchor_b, univ_b, 0), build(anchor_i, univ_i, 1)
db = write_nim(fb, os.path.join(outdir, "blockmappings.nim"))
di = write_nim(fi, os.path.join(outdir, "itemmappings.nim"))
fbm = {i: nm for i, _, nm in fb}
fim = {i: nm for i, _, nm in fi}
alt_b = sum(1 for i, _, nm in anchor_b if fbm.get(i) != nm)
alt_i = sum(1 for i, _, nm in anchor_i if fim.get(i) != nm)
bids = [i for i, _, _ in fb]
bnm = [nm for _, _, nm in fb]
iids = [i for i, _, _ in fi]
inm = [nm for _, _, nm in fi]
compl_b = {nm for _, _, nm in univ_b} <= set(bnm)
compl_i = {nm for _, _, nm in univ_i} <= set(inm)
print(f"blocks={len(fb)} maxid={max(bids)} | items={len(fi)} maxid={max(iids)}")
print(f"block sha256 = {hashlib.sha256(db).hexdigest()}")
print(f"item sha256 = {hashlib.sha256(di).hexdigest()}")
print(f"ANCHOR altered (MUST be 0): block={alt_b} item={alt_i}")
print(f"dup ids: block={len(bids)-len(set(bids))} item={len(iids)-len(set(iids))}"
f" | dup names: block={len(bnm)-len(set(bnm))} item={len(inm)-len(set(inm))}")
print(f"complete (universe names all present): block={compl_b} item={compl_i}")
ok = (alt_b == 0 and alt_i == 0
and len(bids) == len(set(bids)) and len(iids) == len(set(iids))
and len(bnm) == len(set(bnm)) and len(inm) == len(set(inm))
and compl_b and compl_i and max(bids) < 65536 and max(iids) < 65536)
print("GATE PASS:", ok)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.
7dt format (little-endian, no trailing bytes):
[0] u8 version (=6)
[1:5] u32 count
then count * 17-byte records; block id = u16 @ record offset +12,
low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
"""
import sys, struct
sys.path.insert(0, '/home/refuge/nimcanon/tool')
from nimtool import read_nim # -> (records[list of (id,flag,name)], version)
REC = 17; HDR = 5; IDOFF = 12
def load_id2name(path):
if path.endswith('.nim'):
recs, _ = read_nim(path)
return {i: n for i, _, n in recs}
d = {}
for line in open(path, encoding='utf-8', errors='replace'):
line = line.rstrip('\n')
if not line: continue
p = line.split('\t')
if len(p) >= 2 and p[0].isdigit():
d[int(p[0])] = p[1]
return d
def load_name2id(path):
recs, _ = read_nim(path)
return {n: i for i, _, n in recs}
def canon_idset(path):
recs, _ = read_nim(path)
return {i for i, _, n in recs}
def decode_ids(b):
ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
out = []
for r in range(count):
pos = HDR + r*REC + IDOFF
orig = struct.unpack_from('<H', b, pos)[0]
out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
return ver, count, out
def analyze(native, canon, infile):
b = open(infile, 'rb').read()
n2 = load_id2name(native); c2 = load_name2id(canon)
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
unmap_id = sorted(i for i in dist if i not in n2)
nm_for = {i: n2.get(i) for i in dist}
unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
inst_unmap_id = sum(dist[i] for i in unmap_id)
inst_unmap_nm = sum(dist[i] for i in dist if nm_for[i] in (None,) or (nm_for[i] is not None and nm_for[i] not in c2))
print(f" file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
print(f" distinct ids NOT in native table: {len(unmap_id)} (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
if unmap_id[:8]: print(f" e.g. {unmap_id[:8]}")
print(f" distinct names NOT in canon: {len(unmap_name)}")
if unmap_name[:8]: print(f" e.g. {unmap_name[:8]}")
nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
print(f" instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
print(f" >>> FULLY REMAPPABLE: {ok}")
return ok
def decodecheck(table, infile):
b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
miss = sum(dist[i] for i in dist if i not in ids)
print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]")
def apply(native, canon, infile, outfile):
src = open(infile, 'rb').read(); b = bytearray(src)
n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
ver, count, recs = decode_ids(b)
changed = 0; bad = []
for (pos, orig, bid, high) in recs:
name = n2.get(bid)
if name is None: bad.append(('noid', bid)); continue
cid = c2.get(name)
if cid is None: bad.append(('noname', name)); continue
new = high | (cid & 0x7FFF)
if new != orig:
struct.pack_into('<H', b, pos, new); changed += 1
if bad:
print(f" !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
open(outfile, 'wb').write(b)
# self-verify byte safety: same length, only +12/+13 differ, all new ids in canon
assert len(b) == len(src), "LENGTH CHANGED"
diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
ver2, count2, recs2 = decode_ids(bytes(b))
unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
print(f" wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
print(f" byte-diff outside id field: {len(badpos)} (MUST be 0) ids unresolved in canon after remap: {unresolved} (MUST be 0)")
return len(badpos) == 0 and unresolved == 0
if __name__ == '__main__':
cmd = sys.argv[1]
if cmd == 'analyze': analyze(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
elif cmd == 'apply': sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.
VENDORED, SELF-CONTAINED copy of nim-freeze/remap_7dt.py with nimtool.read_nim
inlined and the host sys.path dependency removed, so it runs inside the
panel-7dtd container (python3-minimal, no extra packages). Keep behaviour
byte-identical to remap_7dt.py — the entrypoint's auto-provision relies on
apply()'s exit code (0 = remapped + self-verified safe, 1 = refuse to start).
7dt format (little-endian, no trailing bytes):
[0] u8 version (=6)
[1:5] u32 count
then count * 17-byte records; block id = u16 @ record offset +12,
low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
"""
import sys, struct
REC = 17; HDR = 5; IDOFF = 12
# --- inlined from nimtool.py (read_nim / parse_nim_bytes), byte-faithful ---
def read_nim(path):
with open(path, "rb") as f:
data = f.read()
return parse_nim_bytes(data)
def parse_nim_bytes(data):
if len(data) < 8:
raise ValueError("file too short for header (%d bytes)" % len(data))
version, count = struct.unpack_from("<II", data, 0)
off = 8
records = []
for i in range(count):
if off + 5 > len(data):
raise ValueError("record %d: truncated header at offset %d (len=%d)" % (i, off, len(data)))
rid, flag = struct.unpack_from("<HH", data, off)
namelen = data[off + 4]
off += 5
if off + namelen > len(data):
raise ValueError("record %d (id=%d): name truncated at offset %d (len=%d)" % (i, rid, off, len(data)))
name = data[off:off + namelen].decode("utf-8")
off += namelen
records.append((rid, flag, name))
if off != len(data):
raise ValueError("trailing bytes: consumed %d of %d" % (off, len(data)))
return records, version
# --- end inlined ---
def load_id2name(path):
if path.endswith('.nim'):
recs, _ = read_nim(path)
return {i: n for i, _, n in recs}
d = {}
for line in open(path, encoding='utf-8', errors='replace'):
line = line.rstrip('\n')
if not line: continue
p = line.split('\t')
if len(p) >= 2 and p[0].isdigit():
d[int(p[0])] = p[1]
return d
def load_name2id(path):
recs, _ = read_nim(path)
return {n: i for i, _, n in recs}
def canon_idset(path):
recs, _ = read_nim(path)
return {i for i, _, n in recs}
def decode_ids(b):
ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
out = []
for r in range(count):
pos = HDR + r*REC + IDOFF
orig = struct.unpack_from('<H', b, pos)[0]
out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
return ver, count, out
def analyze(native, canon, infile):
b = open(infile, 'rb').read()
n2 = load_id2name(native); c2 = load_name2id(canon)
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
unmap_id = sorted(i for i in dist if i not in n2)
nm_for = {i: n2.get(i) for i in dist}
unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
inst_unmap_id = sum(dist[i] for i in unmap_id)
print(f" file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
print(f" distinct ids NOT in native table: {len(unmap_id)} (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
if unmap_id[:8]: print(f" e.g. {unmap_id[:8]}")
print(f" distinct names NOT in canon: {len(unmap_name)}")
if unmap_name[:8]: print(f" e.g. {unmap_name[:8]}")
nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
print(f" instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
print(f" >>> FULLY REMAPPABLE: {ok}")
return ok
def decodecheck(table, infile):
b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
ver, count, recs = decode_ids(b)
from collections import Counter
dist = Counter(r[2] for r in recs)
miss = sum(dist[i] for i in dist if i not in ids)
print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]")
def apply(native, canon, infile, outfile):
src = open(infile, 'rb').read(); b = bytearray(src)
n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
ver, count, recs = decode_ids(b)
changed = 0; bad = []
for (pos, orig, bid, high) in recs:
name = n2.get(bid)
if name is None: bad.append(('noid', bid)); continue
cid = c2.get(name)
if cid is None: bad.append(('noname', name)); continue
new = high | (cid & 0x7FFF)
if new != orig:
struct.pack_into('<H', b, pos, new); changed += 1
if bad:
print(f" !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
open(outfile, 'wb').write(b)
assert len(b) == len(src), "LENGTH CHANGED"
diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
ver2, count2, recs2 = decode_ids(bytes(b))
unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
print(f" wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
print(f" byte-diff outside id field: {len(badpos)} (MUST be 0) ids unresolved in canon after remap: {unresolved} (MUST be 0)")
return len(badpos) == 0 and unresolved == 0
if __name__ == '__main__':
cmd = sys.argv[1]
if cmd == 'analyze': analyze(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
elif cmd == 'apply': sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# repin-existing-to-v2_6.sh — one-shot: pin EXISTING 7DTD instances to the
# frozen v2.6 Steam branch so the controller's version-lock gate protects them.
#
# WHY: instances created before the V3.0-readiness work have no provider_id in
# config_values. The lock-gate then treats them as "not explicitly pinned"
# (empty -> safe default of v2_6 on a plain Update, but NOT gated against a
# deliberate switch). Writing provider_id=v2_6 makes them fully locked: an
# Update re-validates 2.6, and switching versions needs a typed confirm.
#
# SAFETY: metadata-only. This writes a JSON key on the DB row. It does NOT
# restart containers, does NOT run SteamCMD, does NOT touch the running game.
# Only rows that do NOT already have a provider_id are changed (idempotent).
#
# ORDERING (important): run this AFTER the new agent + controller are deployed.
# The agent's module.yaml must contain the `v2_6` provider, otherwise an Update
# that resolves provider_id=v2_6 finds no match and falls back to
# UpdateProviders[0]. (Once the new module.yaml is live, UpdateProviders[0] is
# itself v2_6, so even the fallback is safe — but deploy first to be correct.)
#
# USAGE:
# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh # dry-run (shows what WOULD change)
# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh --apply # actually write
#
# DATABASE_URL must be the same connection string the controller uses (on the
# controller host — kaiten — read it from the controller's env/systemd unit).
# Requires `psql` on PATH.
set -euo pipefail
DB="${DATABASE_URL:-}"
if [[ -z "$DB" ]]; then
echo "ERROR: set DATABASE_URL (same string the controller uses)." >&2
exit 1
fi
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
echo "== Current 7DTD instances and their pinned version =="
psql "$DB" -P pager=off -c "
SELECT instance_id,
COALESCE(config_values->>'provider_id', '(none)') AS provider_id,
status
FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;"
echo
echo "== Rows that WOULD be re-pinned to v2_6 (no provider_id yet) =="
psql "$DB" -P pager=off -c "
SELECT instance_id
FROM instances
WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id')
ORDER BY instance_id;"
if [[ "$APPLY" -ne 1 ]]; then
echo
echo "DRY-RUN. Re-run with --apply to write provider_id=v2_6 to the rows above."
exit 0
fi
echo
echo "== Applying: setting provider_id=v2_6 on un-pinned 7DTD rows =="
psql "$DB" -P pager=off -c "
UPDATE instances
SET config_values = jsonb_set(COALESCE(config_values, '{}'::jsonb), '{provider_id}', '\"v2_6\"', true),
updated_at = NOW()
WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id');"
echo
echo "== Result =="
psql "$DB" -P pager=off -c "
SELECT instance_id, config_values->>'provider_id' AS provider_id
FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;"
echo
echo "Done. Existing 7DTD servers are now pinned to v2.6 (frozen)."
echo "No containers were restarted; nothing was downloaded."
@@ -0,0 +1,133 @@
<?xml version="1.0"?>
<!-- ============================================================ V3.0
serverconfig.xml rendered by panel for 7 Days to Die V3.0 "Dead Hot Summer".
RECONCILED 2026-06-15 against the REAL shipped 3.0 serverconfig.xml from the
experimental dedicated-server install (app 294420 -beta latest_experimental,
buildid 23705258). The kept/removed split below is the authoritative one
from that file — NOT a guess.
V3.0 removes 30 gameplay properties from serverconfig.xml and replaces them
with ONE encoded `SandboxCode` property carrying all 150 in-game Sandbox
Options. The panel generates SandboxCode from per-option settings via
pkg/sandbox (or accepts a pasted/preset code). The 30 removed properties:
GameDifficulty, BlockDamagePlayer, BlockDamageAI, BlockDamageAIBM,
XPMultiplier, DayNightLength, DayLightLength, BiomeProgression, StormFreq,
DeathPenalty, DropOnDeath, DropOnQuit, JarRefund, EnemySpawnMode,
EnemyDifficulty, ZombieFeralSense, ZombieMove, ZombieMoveNight,
ZombieFeralMove, ZombieBMMove, AISmellMode, BloodMoonFrequency,
BloodMoonRange, BloodMoonWarning, BloodMoonEnemyCount, LootAbundance,
LootRespawnDays, AirDropFrequency, AirDropMarker, QuestProgressionDailyLimit
(all now live inside SandboxCode).
Selection: an instance renders THIS template instead of serverconfig.xml.tmpl
when its normalized Steam branch is the 3.0 branch (latest_experimental;
add v3.x when TFP cuts a frozen branch). Wired via module.yaml
config_files[].template_by_branch + ConfigFile.TemplateForBranch /
module.RenderForBranch. 2.6 and earlier keep serverconfig.xml.tmpl untouched.
Manage values via the panel config UI, which populates `.Values.*`.
See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md.
============================================================ -->
<ServerSettings>
<!-- ===== Server representation ===== -->
<property name="ServerName" value="{{ or .Values.server_name "Refuge 7DTD" }}" />
<property name="ServerDescription" value="{{ .Values.server_description }}" />
<property name="ServerWebsiteURL" value="{{ .Values.server_website }}" />
<property name="ServerPassword" value="{{ .Values.server_password }}" />
<property name="ServerLoginConfirmationText" value="{{ .Values.login_confirmation_text }}" />
<property name="Region" value="{{ or .Values.region "NorthAmericaWest" }}" />
<property name="Language" value="{{ or .Values.language "English" }}" />
<!-- ===== Networking ===== -->
<property name="ServerPort" value="{{ or .Values.server_port "26900" }}" />
<property name="ServerVisibility" value="{{ or .Values.server_visibility "2" }}" />
<property name="ServerDisabledNetworkProtocols" value="{{ or .Values.disabled_network_protocols "SteamNetworking" }}" />
<property name="ServerMaxWorldTransferSpeedKiBs" value="{{ or .Values.max_world_transfer_speed "512" }}" />
<!-- ===== Slots ===== -->
<property name="ServerMaxPlayerCount" value="{{ or .Values.max_players "8" }}" />
<property name="ServerReservedSlots" value="{{ or .Values.reserved_slots "0" }}" />
<property name="ServerReservedSlotsPermission" value="{{ or .Values.reserved_slots_permission "100" }}" />
<property name="ServerAdminSlots" value="{{ or .Values.admin_slots "0" }}" />
<property name="ServerAdminSlotsPermission" value="{{ or .Values.admin_slots_permission "0" }}" />
<!-- ===== Admin interfaces ===== -->
<property name="WebDashboardEnabled" value="{{ or .Values.webdash_enabled "true" }}" />
<property name="WebDashboardPort" value="{{ or .Values.WEBDASH_PORT .Values.webdash_port "8080" }}" />
<property name="WebDashboardUrl" value="{{ .Values.webdash_url }}" />
<property name="EnableMapRendering" value="{{ or .Values.enable_map_rendering "true" }}" />
<property name="TelnetEnabled" value="{{ or .Values.telnet_enabled "true" }}" />
<property name="TelnetPort" value="{{ or .Values.telnet_port "8081" }}" />
<property name="TelnetPassword" value="{{ .Values.telnet_password }}" />
<property name="TelnetFailedLoginLimit" value="{{ or .Values.telnet_failed_login_limit "10" }}" />
<property name="TelnetFailedLoginsBlocktime" value="{{ or .Values.telnet_failed_login_blocktime "10" }}" />
<property name="TerminalWindowEnabled" value="{{ or .Values.terminal_window_enabled "true" }}" />
<!-- ===== Folder/file locations ===== -->
<property name="AdminFileName" value="serveradmin.xml" />
<!-- ===== Other technical settings ===== -->
<property name="ServerAllowCrossplay" value="{{ or .Values.allow_crossplay "false" }}" />
<property name="EACEnabled" value="{{ or .Values.eac "true" }}" />
<property name="IgnoreEOSSanctions" value="{{ or .Values.ignore_eos_sanctions "false" }}" />
<property name="HideCommandExecutionLog" value="{{ or .Values.hide_command_log "0" }}" />
<property name="MaxUncoveredMapChunksPerPlayer" value="{{ or .Values.max_uncovered_chunks "131072" }}" />
<property name="PersistentPlayerProfiles" value="{{ or .Values.persistent_player_profiles "false" }}" />
<property name="MaxChunkAge" value="{{ or .Values.max_chunk_age "-1" }}" />
<property name="SaveDataLimit" value="{{ or .Values.save_data_limit "-1" }}" />
<!-- ===== World generation ===== -->
<property name="GameWorld" value="{{ or .Values.world_name "RWG" }}" />
<property name="WorldGenSeed" value="{{ or .Values.world_seed "panel-default" }}" />
<property name="WorldGenSize" value="{{ or .Values.world_size "8192" }}" />
<property name="GameName" value="{{ or .Values.game_name "Refuge" }}" />
<property name="GameMode" value="{{ or .Values.game_mode "GameModeSurvival" }}" />
<!-- ===== Difficulty (KEPT in 3.0 — these survived the SandboxCode move) ===== -->
<property name="PlayerSafeZoneLevel" value="{{ or .Values.player_safe_zone_level "5" }}" />
<property name="PlayerSafeZoneHours" value="{{ or .Values.player_safe_zone_hours "5" }}" />
<!-- ===== Game rules (KEPT) ===== -->
<property name="BuildCreate" value="{{ or .Values.build_create "false" }}" />
<property name="BedrollDeadZoneSize" value="{{ or .Values.bedroll_dead_zone_size "15" }}" />
<property name="BedrollExpiryTime" value="{{ or .Values.bedroll_expiry_time "45" }}" />
<property name="AllowSpawnNearFriend" value="{{ or .Values.allow_spawn_near_friend "2" }}" />
<property name="CameraRestrictionMode" value="{{ or .Values.camera_restriction_mode "0" }}" />
<!-- ===== Performance (KEPT) ===== -->
<property name="MaxSpawnedZombies" value="{{ or .Values.max_spawned_zombies "64" }}" />
<property name="MaxSpawnedAnimals" value="{{ or .Values.max_spawned_animals "50" }}" />
<property name="ServerMaxAllowedViewDistance" value="{{ or .Values.max_view_distance "12" }}" />
<property name="MaxQueuedMeshLayers" value="{{ or .Values.max_queued_mesh_layers "1000" }}" />
<!-- ===== Multiplayer (KEPT) ===== -->
<property name="PartySharedKillRange" value="{{ or .Values.party_shared_kill_range "100" }}" />
<property name="PlayerKillingMode" value="{{ or .Values.pk_mode "3" }}" />
<!-- ===== Land claim options (KEPT) ===== -->
<property name="LandClaimCount" value="{{ or .Values.land_claim_count "5" }}" />
<property name="LandClaimSize" value="{{ or .Values.land_claim_size "41" }}" />
<property name="LandClaimDeadZone" value="{{ or .Values.land_claim_dead_zone "30" }}" />
<property name="LandClaimExpiryTime" value="{{ or .Values.land_claim_expiry_time "7" }}" />
<property name="LandClaimDecayMode" value="{{ or .Values.land_claim_decay_mode "0" }}" />
<property name="LandClaimOnlineDurabilityModifier" value="{{ or .Values.land_claim_online_durability_modifier "4" }}" />
<property name="LandClaimOfflineDurabilityModifier" value="{{ or .Values.land_claim_offline_durability_modifier "4" }}" />
<property name="LandClaimOfflineDelay" value="{{ or .Values.land_claim_offline_delay "0" }}" />
<!-- ===== Dynamic mesh (KEPT) ===== -->
<property name="DynamicMeshEnabled" value="{{ or .Values.dynamic_mesh_enabled "true" }}" />
<property name="DynamicMeshLandClaimOnly" value="{{ or .Values.dynamic_mesh_land_claim_only "true" }}" />
<property name="DynamicMeshLandClaimBuffer" value="{{ or .Values.dynamic_mesh_land_claim_buffer "3" }}" />
<property name="DynamicMeshMaxItemCache" value="{{ or .Values.dynamic_mesh_max_item_cache "3" }}" />
<!-- ===== Twitch (KEPT) ===== -->
<property name="TwitchServerPermission" value="{{ or .Values.twitch_server_permission "90" }}" />
<property name="TwitchBloodMoonAllowed" value="{{ or .Values.twitch_blood_moon_allowed "false" }}" />
<!-- ===== NEW in V3.0: the 150 sandbox options as one encoded string =====
Empty value = the game's built-in default sandbox. The panel writes
either a per-option-generated code (pkg/sandbox) or a pasted/preset code.
Default below = the game's Adventurer difficulty code. -->
<property name="SandboxCode" value="{{ or .Values.sandbox_code "AAAJABJACJADJARFBNC" }}" />
</ServerSettings>
@@ -0,0 +1,119 @@
<?xml version="1.0"?>
<!-- serverconfig.xml rendered by panel — do not edit by hand; changes will be
overwritten on the next instance (re)create. Manage values via the panel
config UI, which populates the `.Values.*` keys used below. -->
<ServerSettings>
<!-- GENERAL SERVER SETTINGS -->
<!-- Server representation -->
<property name="ServerName" value="{{ or .Values.server_name "Refuge 7DTD" }}" />
<property name="ServerDescription" value="{{ .Values.server_description }}" />
<property name="ServerWebsiteURL" value="{{ .Values.server_website }}" />
<property name="ServerPassword" value="{{ .Values.server_password }}" />
<property name="ServerLoginConfirmationText" value="{{ .Values.login_confirmation_text }}" />
<property name="Region" value="{{ or .Values.region "NorthAmericaWest" }}" />
<property name="Language" value="{{ or .Values.language "English" }}" />
<!-- Networking -->
<property name="ServerPort" value="{{ or .Values.server_port "26900" }}" />
<property name="ServerVisibility" value="{{ or .Values.server_visibility "2" }}" />
<property name="ServerDisabledNetworkProtocols" value="{{ or .Values.disabled_network_protocols "SteamNetworking" }}" />
<property name="ServerMaxWorldTransferSpeedKiBs" value="{{ or .Values.max_world_transfer_speed "1300" }}" />
<!-- Slots -->
<property name="ServerMaxPlayerCount" value="{{ or .Values.max_players "40" }}" />
<property name="ServerReservedSlots" value="{{ or .Values.reserved_slots "0" }}" />
<property name="ServerReservedSlotsPermission" value="{{ or .Values.reserved_slots_permission "100" }}" />
<property name="ServerAdminSlots" value="{{ or .Values.admin_slots "0" }}" />
<property name="ServerAdminSlotsPermission" value="{{ or .Values.admin_slots_permission "0" }}" />
<!-- Admin interfaces -->
<property name="WebDashboardEnabled" value="{{ or .Values.webdash_enabled "true" }}" />
<property name="WebDashboardPort" value="{{ or .Values.WEBDASH_PORT .Values.webdash_port "8080" }}" />
<property name="WebDashboardUrl" value="{{ .Values.webdash_url }}" />
<property name="EnableMapRendering" value="{{ or .Values.enable_map_rendering "true" }}" />
<property name="TelnetEnabled" value="{{ or .Values.telnet_enabled "true" }}" />
<property name="TelnetPort" value="{{ or .Values.telnet_port "8081" }}" />
<property name="TelnetPassword" value="{{ .Values.telnet_password }}" />
<property name="TelnetFailedLoginLimit" value="{{ or .Values.telnet_failed_login_limit "10" }}" />
<property name="TelnetFailedLoginsBlocktime" value="{{ or .Values.telnet_failed_login_blocktime "10" }}" />
<property name="TerminalWindowEnabled" value="{{ or .Values.terminal_window_enabled "true" }}" />
<!-- Folder and file locations -->
<property name="AdminFileName" value="serveradmin.xml" />
<!-- Other technical settings -->
<property name="ServerAllowCrossplay" value="{{ or .Values.allow_crossplay "false" }}" />
<property name="EACEnabled" value="{{ or .Values.eac "true" }}" />
<property name="IgnoreEOSSanctions" value="{{ or .Values.ignore_eos_sanctions "false" }}" />
<property name="HideCommandExecutionLog" value="{{ or .Values.hide_command_log "0" }}" />
<property name="MaxUncoveredMapChunksPerPlayer" value="{{ or .Values.max_uncovered_chunks "131072" }}" />
<property name="PersistentPlayerProfiles" value="{{ or .Values.persistent_player_profiles "true" }}" />
<property name="MaxChunkAge" value="{{ or .Values.max_chunk_age "-1" }}" />
<property name="SaveDataLimit" value="{{ or .Values.save_data_limit "-1" }}" />
<!-- GAMEPLAY -->
<!-- World -->
<property name="GameWorld" value="{{ or .Values.world_name "RWG" }}" />
<property name="WorldGenSeed" value="{{ or .Values.world_seed "panel-default" }}" />
<property name="WorldGenSize" value="{{ or .Values.world_size "8192" }}" />
<property name="GameName" value="{{ or .Values.game_name "Refuge" }}" />
<property name="GameMode" value="{{ or .Values.game_mode "GameModeSurvival" }}" />
<!-- Difficulty -->
<property name="GameDifficulty" value="{{ or .Values.difficulty "2" }}" />
<property name="BlockDamagePlayer" value="{{ or .Values.block_damage_player "100" }}" />
<property name="BlockDamageAI" value="{{ or .Values.block_damage_ai "100" }}" />
<property name="BlockDamageAIBM" value="{{ or .Values.block_damage_ai_bm "100" }}" />
<property name="XPMultiplier" value="{{ or .Values.xp_multiplier "60" }}" />
<property name="PlayerSafeZoneLevel" value="{{ or .Values.player_safe_zone_level "5" }}" />
<property name="PlayerSafeZoneHours" value="{{ or .Values.player_safe_zone_hours "5" }}" />
<!-- Game Rules -->
<property name="BuildCreate" value="{{ or .Values.build_create "false" }}" />
<property name="DayNightLength" value="{{ or .Values.day_length_minutes "60" }}" />
<property name="DayLightLength" value="{{ or .Values.daylight_hours "18" }}" />
<property name="BiomeProgression" value="{{ or .Values.biome_progression "true" }}" />
<property name="StormFreq" value="{{ or .Values.storm_freq "100" }}" />
<property name="DeathPenalty" value="{{ or .Values.death_penalty "1" }}" />
<property name="DropOnDeath" value="{{ or .Values.drop_on_death "3" }}" />
<property name="DropOnQuit" value="{{ or .Values.drop_on_quit "0" }}" />
<property name="BedrollDeadZoneSize" value="{{ or .Values.bedroll_dead_zone_size "15" }}" />
<property name="BedrollExpiryTime" value="{{ or .Values.bedroll_expiry_time "45" }}" />
<property name="AllowSpawnNearFriend" value="{{ or .Values.allow_spawn_near_friend "2" }}" />
<property name="CameraRestrictionMode" value="{{ or .Values.camera_restriction_mode "0" }}" />
<property name="JarRefund" value="{{ or .Values.jar_refund "50" }}" />
<!-- Performance related -->
<property name="MaxSpawnedZombies" value="{{ or .Values.max_spawned_zombies "80" }}" />
<property name="MaxSpawnedAnimals" value="{{ or .Values.max_spawned_animals "50" }}" />
<property name="ServerMaxAllowedViewDistance" value="{{ or .Values.max_view_distance "8" }}" />
<property name="MaxQueuedMeshLayers" value="{{ or .Values.max_queued_mesh_layers "1000" }}" />
<!-- Zombie settings -->
<property name="EnemySpawnMode" value="{{ or .Values.enemy_spawn_mode "true" }}" />
<property name="EnemyDifficulty" value="{{ or .Values.enemy_difficulty "0" }}" />
<property name="ZombieFeralSense" value="{{ or .Values.zombie_feral_sense "0" }}" />
<property name="ZombieMove" value="{{ or .Values.zombie_move "0" }}" />
<property name="ZombieMoveNight" value="{{ or .Values.zombie_move_night "3" }}" />
<property name="ZombieFeralMove" value="{{ or .Values.zombie_feral_move "3" }}" />
<property name="ZombieBMMove" value="{{ or .Values.zombie_bm_move "4" }}" />
<property name="AISmellMode" value="{{ or .Values.ai_smell_mode "3" }}" />
<property name="BloodMoonFrequency" value="{{ or .Values.blood_moon_frequency "7" }}" />
<property name="BloodMoonRange" value="{{ or .Values.blood_moon_range "0" }}" />
<property name="BloodMoonWarning" value="{{ or .Values.blood_moon_warning "8" }}" />
<property name="BloodMoonEnemyCount" value="{{ or .Values.blood_moon_enemy_count "6" }}" />
<!-- Loot -->
<property name="LootAbundance" value="{{ or .Values.loot_abundance "50" }}" />
<property name="LootRespawnDays" value="{{ or .Values.loot_respawn_days "2" }}" />
<property name="AirDropFrequency" value="{{ or .Values.airdrop_frequency "24" }}" />
<property name="AirDropMarker" value="{{ or .Values.airdrop_marker "true" }}" />
<!-- Multiplayer -->
<property name="PartySharedKillRange" value="{{ or .Values.party_shared_kill_range "200" }}" />
<property name="PlayerKillingMode" value="{{ or .Values.pk_mode "0" }}" />
<!-- Land claim options -->
<property name="LandClaimCount" value="{{ or .Values.land_claim_count "12" }}" />
<property name="LandClaimSize" value="{{ or .Values.land_claim_size "21" }}" />
<property name="LandClaimDeadZone" value="{{ or .Values.land_claim_dead_zone "5" }}" />
<property name="LandClaimExpiryTime" value="{{ or .Values.land_claim_expiry_time "30" }}" />
<property name="LandClaimDecayMode" value="{{ or .Values.land_claim_decay_mode "2" }}" />
<property name="LandClaimOnlineDurabilityModifier" value="{{ or .Values.land_claim_online_durability_modifier "0" }}" />
<property name="LandClaimOfflineDurabilityModifier" value="{{ or .Values.land_claim_offline_durability_modifier "0" }}" />
<property name="LandClaimOfflineDelay" value="{{ or .Values.land_claim_offline_delay "0" }}" />
<property name="DynamicMeshEnabled" value="{{ or .Values.dynamic_mesh_enabled "false" }}" />
<property name="DynamicMeshLandClaimOnly" value="{{ or .Values.dynamic_mesh_land_claim_only "false" }}" />
<property name="DynamicMeshLandClaimBuffer" value="{{ or .Values.dynamic_mesh_land_claim_buffer "3" }}" />
<property name="DynamicMeshMaxItemCache" value="{{ or .Values.dynamic_mesh_max_item_cache "3" }}" />
<property name="TwitchServerPermission" value="{{ or .Values.twitch_server_permission "90" }}" />
<property name="TwitchBloodMoonAllowed" value="{{ or .Values.twitch_blood_moon_allowed "false" }}" />
<property name="QuestProgressionDailyLimit" value="{{ or .Values.quest_daily_limit "15" }}" />
</ServerSettings>