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